From ee727b8bc78f81043fdbda739ef29c74f5d7e833 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 6 May 2026 15:02:09 -0400 Subject: [PATCH 001/201] fix(jetbrains): render markdown code blocks with scrollable components --- .../client/session/ui/EmptySessionPanel.kt | 6 +- .../client/session/views/ReasoningView.kt | 4 +- .../kilocode/client/session/views/TextView.kt | 4 +- .../ai/kilocode/client/ui/md/MdCommon.kt | 77 ++++ .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 375 ++------------- .../ai/kilocode/client/ui/md/MdViewFactory.kt | 11 + .../kilocode/client/ui/md/MdViewHtmlPane.kt | 290 ++++++++++++ .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 428 ++++++++++++++++++ .../session/update/SessionRecoveryTest.kt | 15 +- .../client/ui/md/MdViewFactoryTest.kt | 27 ++ .../kilocode/client/ui/md/MdViewHybridTest.kt | 77 ++++ .../client/ui/md/MdViewLoggingTest.kt | 2 +- .../ai/kilocode/client/ui/md/MdViewTest.kt | 34 +- 13 files changed, 972 insertions(+), 378 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 1caf26b538d..22ee56dc73b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer @@ -84,7 +84,7 @@ class EmptySessionPanel( } }) } - private val md = MdView.html().apply { + private val md = MdViewFactory.html(style).apply { // MdView uses an HTML component; transparency keeps the centered panel seamless. opaque = false foreground = UIUtil.getContextHelpForeground() @@ -242,7 +242,9 @@ class EmptySessionPanel( override fun applyStyle(style: SessionStyle) { this.style = style + md.applyStyle(style) md.font = style.uiFont + md.foreground = UIUtil.getContextHelpForeground() recentTitle.font = style.smallUiFont revalidate() repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 4e2f143e1ae..15805832c75 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -8,6 +8,7 @@ import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.SessionStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane @@ -28,7 +29,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() { override val contentId: String = reasoning.id - val md: MdView = MdView.html() + val md: MdView = MdViewFactory.create(SessionStyle.current()) private val arrow = JBLabel() private val body = TrackPanel().apply { @@ -153,6 +154,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() { title.font = style.smallEditorFont changed = true } + md.applyStyle(style) changed = apply(md) || changed if (changed) refresh() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 1954aaa8a48..4f3a3131ab5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.SessionStyle import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory import java.awt.BorderLayout /** @@ -15,7 +16,7 @@ class TextView(text: Text) : PartView() { override val contentId: String = text.id - val md: MdView = MdView.html() + val md: MdView = MdViewFactory.create(SessionStyle.current()) init { layout = BorderLayout() @@ -42,6 +43,7 @@ class TextView(text: Text) : PartView() { override fun applyStyle(style: SessionStyle) { val changed = md.font != style.transcriptFont || md.codeFont != style.editorFamily + md.applyStyle(style) if (md.font != style.transcriptFont) md.font = style.transcriptFont if (md.codeFont != style.editorFamily) md.codeFont = style.editorFamily if (!changed) return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt new file mode 100644 index 00000000000..5cd76747ded --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -0,0 +1,77 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import java.awt.Color + +internal object MdCommon { + val tags = listOf( + "body", "p", "div", "span", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td", + "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "a", "tt", "code", "samp", "pre", + ) + + fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) + + fun css(text: String): String = text + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", " ") + .replace("\r", " ") + + fun rules(opts: MdStyle): String { + val rules = StringBuilder() + + val text = mutableListOf() + text.add("color: ${hex(opts.foreground)}") + text.add("font-family: '${css(opts.font.name)}', sans-serif") + text.add("font-size: ${opts.font.size}pt") + if (opts.font.isItalic) text.add("font-style: italic") + if (opts.font.isBold) text.add("font-weight: bold") + val rule = text.joinToString("; ") + for (tag in tags) rules.append("$tag { $rule } ") + + val body = mutableListOf() + if (!opts.opaque) body.add("background: transparent") + if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + + rules.append("a { color: ${hex(opts.linkColor)} } ") + rules.append("tt, code, samp, pre { font-family: '${css(opts.codeFont)}', monospace } ") + rules.append("pre { background: ${hex(opts.preBg)} } ") + rules.append("pre { color: ${hex(opts.preFg)} } ") + rules.append("code { background: ${hex(opts.codeBg)} } ") + rules.append("blockquote { border-left-color: ${hex(opts.quoteBorder)} } ") + rules.append("blockquote { color: ${hex(opts.quoteFg)} } ") + rules.append("th, td { border-color: ${hex(opts.tableBorder)} } ") + + return rules.toString().trim() + } + + fun defaults(style: SessionStyle) = MdStyle( + font = style.transcriptFont, + foreground = com.intellij.util.ui.UIUtil.getLabelForeground(), + background = style.editorScheme.defaultBackground, + linkColor = com.intellij.util.ui.JBUI.CurrentTheme.Link.Foreground.ENABLED, + codeBg = style.editorScheme.defaultBackground, + preBg = style.editorScheme.defaultBackground, + preFg = style.editorScheme.defaultForeground, + codeFont = style.editorFamily, + quoteBorder = com.intellij.ui.JBColor.border(), + quoteFg = com.intellij.util.ui.UIUtil.getContextHelpForeground(), + tableBorder = com.intellij.ui.JBColor.border(), + opaque = true, + ) +} + +internal data class MdStyle( + val font: java.awt.Font, + val foreground: Color, + val background: Color, + val linkColor: Color, + val codeBg: Color, + val preBg: Color, + val preFg: Color, + val codeFont: String, + val quoteBorder: Color, + val quoteFg: Color, + val tableBorder: Color, + val opaque: Boolean, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 2f299325292..141a1f27ec3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,61 +1,35 @@ package ai.kilocode.client.ui.md -import ai.kilocode.log.KiloLog -import com.intellij.ui.components.JBHtmlPane -import com.intellij.ui.components.JBHtmlPaneConfiguration -import com.intellij.ui.components.JBHtmlPaneStyleConfiguration -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import org.commonmark.ext.autolink.AutolinkExtension -import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension -import org.commonmark.ext.gfm.tables.TablesExtension -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer +import ai.kilocode.client.session.ui.SessionStyle import java.awt.Color import java.awt.Font import java.awt.Point import javax.swing.JComponent -import javax.swing.event.HyperlinkEvent -import javax.swing.text.html.StyleSheet -/** - * Markdown rendering component backed by [JBHtmlPane] with editor-aware styling. - * - * By default, font and colors are derived from the global editor colour scheme. - * All style properties are optional overrides on top of those defaults. - * Call [resetStyles] to revert to editor defaults after overriding. - * - * Create instances via [MdView.html]. All public methods must be called on the EDT. - */ -@Suppress("UnstableApiUsage") -abstract class MdView private constructor() { +/** Markdown rendering component. All public methods must be called on the EDT. */ +interface MdView { + val component: JComponent - abstract val component: JComponent - abstract fun set(text: String) - abstract fun append(delta: String) - abstract fun clear() - /** Revert all style overrides to editor-derived defaults. */ - abstract fun resetStyles() - abstract fun addLinkListener(listener: LinkListener) - abstract fun removeLinkListener(listener: LinkListener) + fun set(text: String) + fun append(delta: String) + fun clear() + fun applyStyle(style: SessionStyle) + fun resetStyles() + fun addLinkListener(listener: LinkListener) + fun removeLinkListener(listener: LinkListener) - abstract var font: Font - abstract var foreground: Color - abstract var background: Color - abstract var linkColor: Color - abstract var codeBg: Color - abstract var preBg: Color - abstract var preFg: Color - abstract var codeFont: String - abstract var quoteBorder: Color - abstract var quoteFg: Color - abstract var tableBorder: Color - - /** - * When `false`, the component is transparent — the parent's background shows through - * and no background is forced in the CSS body rule. - */ - abstract var opaque: Boolean + var font: Font + var foreground: Color + var background: Color + var linkColor: Color + var codeBg: Color + var preBg: Color + var preFg: Color + var codeFont: String + var quoteBorder: Color + var quoteFg: Color + var tableBorder: Color + var opaque: Boolean data class LinkEvent( val href: String, @@ -66,303 +40,8 @@ abstract class MdView private constructor() { fun onLink(event: LinkEvent) } - internal abstract fun markdown(): String - internal abstract fun html(): String - /** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */ - internal abstract fun overrideSheet(): String - internal abstract fun simulateLink(href: String) - - companion object { - fun html(): MdView = HtmlImpl() - } - - @Suppress("UnstableApiUsage") - private class HtmlImpl : MdView() { - companion object { - private val LOG = KiloLog.create(HtmlImpl::class.java) - private val TAGS = listOf( - "body", "p", "div", "span", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td", - "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "a", "tt", "code", "samp", "pre", - ) - - private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) - - private fun css(text: String): String = text - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\n", " ") - .replace("\r", " ") - } - - private val listeners = mutableListOf() - private val source = StringBuilder() - private var rendered = "" - - private val extensions = listOf( - AutolinkExtension.create(), - TablesExtension.create(), - StrikethroughExtension.create(), - ) - - private val parser: Parser = Parser.builder().extensions(extensions).build() - - private val renderer: HtmlRenderer = HtmlRenderer.builder() - .extensions(extensions) - .escapeHtml(true) - .sanitizeUrls(true) - .build() - - // nullable overrides — null means "use JBHtmlPane / editor default" - private var fontOverride: Font? = null - private var foregroundOverride: Color? = null - private var backgroundOverride: Color? = null - private var linkColorOverride: Color? = null - private var codeBgOverride: Color? = null - private var preBgOverride: Color? = null - private var preFgOverride: Color? = null - private var codeFontOverride: String? = null - private var quoteBorderOverride: Color? = null - private var quoteFgOverride: Color? = null - private var tableBorderOverride: Color? = null - private var opaqueState = true - - private val pane: JBHtmlPane = JBHtmlPane( - JBHtmlPaneStyleConfiguration { - // colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme - enableInlineCodeBackground = true - enableCodeBlocksBackground = true - }, - JBHtmlPaneConfiguration { - // fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService - customStyleSheetProvider { buildOverrideStyleSheet() } - } - ).apply { - isEditable = false - isOpaque = true - background = UIUtil.getPanelBackground() - - addHyperlinkListener { e -> - if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { - val href = e.description ?: return@addHyperlinkListener - val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point - val event = LinkEvent(href, pt) - for (l in listeners) l.onLink(event) - } - } - } - - override val component: JComponent get() = pane - - // -- style properties (non-null API backed by nullable overrides) ---- - - override var font: Font - get() = fontOverride ?: JBUI.Fonts.label() - set(value) { - if (fontOverride == value) return - fontOverride = value - markDirty() - } - - override var foreground: Color - get() = foregroundOverride ?: UIUtil.getLabelForeground() - set(value) { - if (foregroundOverride == value) return - foregroundOverride = value - markDirty() - } - - override var background: Color - get() = backgroundOverride ?: pane.background - set(value) { - if (backgroundOverride == value) return - backgroundOverride = value - if (opaqueState) pane.background = value - markDirty() - } - - override var linkColor: Color - get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6) - set(value) { - if (linkColorOverride == value) return - linkColorOverride = value - markDirty() - } - - override var codeBg: Color - get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41) - set(value) { - if (codeBgOverride == value) return - codeBgOverride = value - markDirty() - } - - override var preBg: Color - get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B) - set(value) { - if (preBgOverride == value) return - preBgOverride = value - markDirty() - } - - override var preFg: Color - get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6) - set(value) { - if (preFgOverride == value) return - preFgOverride = value - markDirty() - } - - override var codeFont: String - // _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font - get() = codeFontOverride ?: "_EditorFontNoLigatures_" - set(value) { - if (codeFontOverride == value) return - codeFontOverride = value - markDirty() - } - - override var quoteBorder: Color - get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55) - set(value) { - if (quoteBorderOverride == value) return - quoteBorderOverride = value - markDirty() - } - - override var quoteFg: Color - get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99) - set(value) { - if (quoteFgOverride == value) return - quoteFgOverride = value - markDirty() - } - - override var tableBorder: Color - get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55) - set(value) { - if (tableBorderOverride == value) return - tableBorderOverride = value - markDirty() - } - - override var opaque: Boolean - get() = opaqueState - set(value) { - if (opaqueState == value) return - opaqueState = value - pane.isOpaque = value - if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground() - markDirty() - } - - override fun resetStyles() { - fontOverride = null - foregroundOverride = null - backgroundOverride = null - linkColorOverride = null - codeBgOverride = null - preBgOverride = null - preFgOverride = null - codeFontOverride = null - quoteBorderOverride = null - quoteFgOverride = null - tableBorderOverride = null - opaqueState = true - pane.isOpaque = true - pane.background = UIUtil.getPanelBackground() - markDirty() - } - - // -- content API --------------------------------------------------- - - override fun set(text: String) { - if (source.toString() == text) return - source.clear() - source.append(text) - syncHtml() - } - - override fun append(delta: String) { - if (delta.isEmpty()) return - source.append(delta) - syncHtml() - } - - override fun clear() { - if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return - source.clear() - rendered = "" - pane.text = "" - } - - override fun addLinkListener(listener: LinkListener) { listeners.add(listener) } - override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) } - - override fun markdown(): String = source.toString() - override fun html(): String = rendered - override fun overrideSheet(): String = buildOverrideRulesString() - - override fun simulateLink(href: String) { - val event = LinkEvent(href) - for (l in listeners) l.onLink(event) - } - - private fun markDirty() { - pane.reloadCssStylesheets() - if (source.isNotEmpty()) syncHtml() - } - - private fun syncHtml() { - val body = renderer.render(parser.parse(source.toString())) - if (rendered == body && pane.text == "$body") return - rendered = body - pane.text = "$body" - pane.caretPosition = 0 - } - - private fun buildOverrideStyleSheet(): StyleSheet { - val sheet = StyleSheet() - val rules = buildOverrideRulesString() - if (rules.isNotEmpty()) { - try { - sheet.addRule(rules) - } catch (err: Exception) { - LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) - } - } - return sheet - } - - private fun buildOverrideRulesString(): String { - val rules = StringBuilder() - - val text = mutableListOf() - foregroundOverride?.let { text.add("color: ${hex(it)}") } - fontOverride?.let { - text.add("font-family: '${css(it.name)}', sans-serif") - text.add("font-size: ${it.size}pt") - if (it.isItalic) text.add("font-style: italic") - if (it.isBold) text.add("font-weight: bold") - } - if (text.isNotEmpty()) { - val rule = text.joinToString("; ") - for (tag in TAGS) rules.append("$tag { $rule } ") - } - - val body = mutableListOf() - if (!opaqueState) body.add("background: transparent") - if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") - - linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } - codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${css(it)}', monospace } ") } - preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } - preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } - codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } - quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } - quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") } - tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") } - - return rules.toString().trim() - } - } + fun markdown(): String + fun html(): String + fun overrideSheet(): String + fun simulateLink(href: String) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt new file mode 100644 index 00000000000..8646b317560 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt @@ -0,0 +1,11 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle + +object MdViewFactory { + fun create(style: SessionStyle = SessionStyle.current()): MdView = hybrid(style) + + fun hybrid(style: SessionStyle = SessionStyle.current()): MdView = MdViewHybrid(style) + + fun html(style: SessionStyle = SessionStyle.current()): MdView = MdViewHtmlPane(style) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt new file mode 100644 index 00000000000..02dcbcf3f80 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt @@ -0,0 +1,290 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.log.KiloLog +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Font +import javax.swing.JComponent +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.StyleSheet + +@Suppress("UnstableApiUsage") +internal class MdViewHtmlPane( + style: SessionStyle = SessionStyle.current(), +) : MdView { + companion object { + private val LOG = KiloLog.create(MdViewHtmlPane::class.java) + } + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var rendered = "" + private var style = style + + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder().extensions(extensions).build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) + .build() + + private var fontOverride: Font? = null + private var foregroundOverride: Color? = null + private var backgroundOverride: Color? = null + private var linkColorOverride: Color? = null + private var codeBgOverride: Color? = null + private var preBgOverride: Color? = null + private var preFgOverride: Color? = null + private var codeFontOverride: String? = null + private var quoteBorderOverride: Color? = null + private var quoteFgOverride: Color? = null + private var tableBorderOverride: Color? = null + private var opaqueState = true + + private val pane: JBHtmlPane = JBHtmlPane( + JBHtmlPaneStyleConfiguration { + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + customStyleSheetProvider { buildOverrideStyleSheet() } + }, + ).apply { + isEditable = false + isOpaque = true + background = opts().background + + addHyperlinkListener { e -> + if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener + val href = e.description ?: return@addHyperlinkListener + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point + dispatch(MdView.LinkEvent(href, pt)) + } + } + + override val component: JComponent get() = pane + + override var font: Font + get() = fontOverride ?: opts().font + set(value) { + if (fontOverride == value) return + fontOverride = value + markDirty() + } + + override var foreground: Color + get() = foregroundOverride ?: opts().foreground + set(value) { + if (foregroundOverride == value) return + foregroundOverride = value + markDirty() + } + + override var background: Color + get() = backgroundOverride ?: opts().background + set(value) { + if (backgroundOverride == value) return + backgroundOverride = value + if (opaqueState) pane.background = value + markDirty() + } + + override var linkColor: Color + get() = linkColorOverride ?: opts().linkColor + set(value) { + if (linkColorOverride == value) return + linkColorOverride = value + markDirty() + } + + override var codeBg: Color + get() = codeBgOverride ?: opts().codeBg + set(value) { + if (codeBgOverride == value) return + codeBgOverride = value + markDirty() + } + + override var preBg: Color + get() = preBgOverride ?: opts().preBg + set(value) { + if (preBgOverride == value) return + preBgOverride = value + markDirty() + } + + override var preFg: Color + get() = preFgOverride ?: opts().preFg + set(value) { + if (preFgOverride == value) return + preFgOverride = value + markDirty() + } + + override var codeFont: String + get() = codeFontOverride ?: opts().codeFont + set(value) { + if (codeFontOverride == value) return + codeFontOverride = value + markDirty() + } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: opts().quoteBorder + set(value) { + if (quoteBorderOverride == value) return + quoteBorderOverride = value + markDirty() + } + + override var quoteFg: Color + get() = quoteFgOverride ?: opts().quoteFg + set(value) { + if (quoteFgOverride == value) return + quoteFgOverride = value + markDirty() + } + + override var tableBorder: Color + get() = tableBorderOverride ?: opts().tableBorder + set(value) { + if (tableBorderOverride == value) return + tableBorderOverride = value + markDirty() + } + + override var opaque: Boolean + get() = opaqueState + set(value) { + if (opaqueState == value) return + opaqueState = value + pane.isOpaque = value + if (value) pane.background = background + markDirty() + } + + override fun applyStyle(style: SessionStyle) { + if (this.style == style) return + this.style = style + if (opaqueState) pane.background = background + markDirty() + } + + override fun resetStyles() { + fontOverride = null + foregroundOverride = null + backgroundOverride = null + linkColorOverride = null + codeBgOverride = null + preBgOverride = null + preFgOverride = null + codeFontOverride = null + quoteBorderOverride = null + quoteFgOverride = null + tableBorderOverride = null + opaqueState = true + pane.isOpaque = true + pane.background = background + markDirty() + } + + override fun set(text: String) { + if (source.toString() == text) return + source.clear() + source.append(text) + syncHtml() + } + + override fun append(delta: String) { + if (delta.isEmpty()) return + source.append(delta) + syncHtml() + } + + override fun clear() { + if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return + source.clear() + rendered = "" + pane.text = "" + } + + override fun addLinkListener(listener: MdView.LinkListener) { + listeners.add(listener) + } + + override fun removeLinkListener(listener: MdView.LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String = rendered + + override fun overrideSheet(): String = MdCommon.rules(opts()) + + override fun simulateLink(href: String) { + dispatch(MdView.LinkEvent(href)) + } + + private fun dispatch(event: MdView.LinkEvent) { + for (l in listeners) l.onLink(event) + } + + private fun markDirty() { + pane.reloadCssStylesheets() + if (source.isNotEmpty()) syncHtml() + } + + private fun syncHtml() { + val body = renderer.render(parser.parse(source.toString())) + if (rendered == body && pane.text == "$body") return + rendered = body + pane.text = "$body" + pane.caretPosition = 0 + } + + private fun buildOverrideStyleSheet(): StyleSheet { + val sheet = StyleSheet() + val rules = overrideSheet() + if (rules.isEmpty()) return sheet + try { + sheet.addRule(rules) + } catch (err: Exception) { + LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) + } + return sheet + } + + private fun opts(): MdStyle { + val base = MdCommon.defaults(style) + return base.copy( + font = fontOverride ?: base.font, + foreground = foregroundOverride ?: base.foreground, + background = backgroundOverride ?: base.background, + linkColor = linkColorOverride ?: base.linkColor, + codeBg = codeBgOverride ?: base.codeBg, + preBg = preBgOverride ?: base.preBg, + preFg = preFgOverride ?: base.preFg, + codeFont = codeFontOverride ?: base.codeFont, + quoteBorder = quoteBorderOverride ?: base.quoteBorder, + quoteFg = quoteFgOverride ?: base.quoteFg, + tableBorder = tableBorderOverride ?: base.tableBorder, + opaque = opaqueState, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt new file mode 100644 index 00000000000..88f8ef711e6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -0,0 +1,428 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.log.KiloLog +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.node.AbstractVisitor +import org.commonmark.node.Block +import org.commonmark.node.Document +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Node +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Font +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.StyleSheet + +@Suppress("UnstableApiUsage") +internal class MdViewHybrid( + style: SessionStyle = SessionStyle.current(), +) : MdView { + companion object { + private val LOG = KiloLog.create(MdViewHybrid::class.java) + } + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var style = style + private var rendered = "" + + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder().extensions(extensions).build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) + .build() + + private var fontOverride: Font? = null + private var foregroundOverride: Color? = null + private var backgroundOverride: Color? = null + private var linkColorOverride: Color? = null + private var codeBgOverride: Color? = null + private var preBgOverride: Color? = null + private var preFgOverride: Color? = null + private var codeFontOverride: String? = null + private var quoteBorderOverride: Color? = null + private var quoteFgOverride: Color? = null + private var tableBorderOverride: Color? = null + private var opaqueState = true + + private val root = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = true + background = opts().background + } + + override val component: JComponent get() = root + + override var font: Font + get() = fontOverride ?: opts().font + set(value) { + if (fontOverride == value) return + fontOverride = value + syncStyle() + } + + override var foreground: Color + get() = foregroundOverride ?: opts().foreground + set(value) { + if (foregroundOverride == value) return + foregroundOverride = value + syncStyle() + } + + override var background: Color + get() = backgroundOverride ?: opts().background + set(value) { + if (backgroundOverride == value) return + backgroundOverride = value + syncStyle() + } + + override var linkColor: Color + get() = linkColorOverride ?: opts().linkColor + set(value) { + if (linkColorOverride == value) return + linkColorOverride = value + syncStyle() + } + + override var codeBg: Color + get() = codeBgOverride ?: opts().codeBg + set(value) { + if (codeBgOverride == value) return + codeBgOverride = value + syncStyle() + } + + override var preBg: Color + get() = preBgOverride ?: opts().preBg + set(value) { + if (preBgOverride == value) return + preBgOverride = value + syncStyle() + } + + override var preFg: Color + get() = preFgOverride ?: opts().preFg + set(value) { + if (preFgOverride == value) return + preFgOverride = value + syncStyle() + } + + override var codeFont: String + get() = codeFontOverride ?: opts().codeFont + set(value) { + if (codeFontOverride == value) return + codeFontOverride = value + syncStyle() + } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: opts().quoteBorder + set(value) { + if (quoteBorderOverride == value) return + quoteBorderOverride = value + syncStyle() + } + + override var quoteFg: Color + get() = quoteFgOverride ?: opts().quoteFg + set(value) { + if (quoteFgOverride == value) return + quoteFgOverride = value + syncStyle() + } + + override var tableBorder: Color + get() = tableBorderOverride ?: opts().tableBorder + set(value) { + if (tableBorderOverride == value) return + tableBorderOverride = value + syncStyle() + } + + override var opaque: Boolean + get() = opaqueState + set(value) { + if (opaqueState == value) return + opaqueState = value + syncStyle() + } + + override fun applyStyle(style: SessionStyle) { + if (this.style == style) return + this.style = style + syncStyle() + } + + override fun resetStyles() { + fontOverride = null + foregroundOverride = null + backgroundOverride = null + linkColorOverride = null + codeBgOverride = null + preBgOverride = null + preFgOverride = null + codeFontOverride = null + quoteBorderOverride = null + quoteFgOverride = null + tableBorderOverride = null + opaqueState = true + syncStyle() + } + + override fun set(text: String) { + if (source.toString() == text) return + source.clear() + source.append(text) + syncBlocks() + } + + override fun append(delta: String) { + if (delta.isEmpty()) return + source.append(delta) + syncBlocks() + } + + override fun clear() { + if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return + source.clear() + rendered = "" + root.removeAll() + root.revalidate() + root.repaint() + } + + override fun addLinkListener(listener: MdView.LinkListener) { + listeners.add(listener) + } + + override fun removeLinkListener(listener: MdView.LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String = rendered + + override fun overrideSheet(): String = MdCommon.rules(opts()) + + override fun simulateLink(href: String) { + dispatch(MdView.LinkEvent(href)) + } + + private fun syncStyle() { + val opts = opts() + root.isOpaque = opts.opaque + if (opts.opaque) root.background = opts.background + syncBlocks() + } + + private fun syncBlocks() { + val text = source.toString() + val doc = parser.parse(text) + val body = renderer.render(doc) + rendered = body + root.removeAll() + if (text.isEmpty()) { + root.revalidate() + root.repaint() + return + } + val visitor = Visitor() + doc.accept(visitor) + root.revalidate() + root.repaint() + } + + private fun addGap() { + if (root.componentCount == 0) return + root.add(Box.createVerticalStrut(JBUI.scale(6))) + } + + private fun addBlock(component: JComponent) { + addGap() + component.alignmentX = JComponent.LEFT_ALIGNMENT + root.add(component) + } + + private fun htmlBlock(node: Node): JComponent { + val opts = opts() + val body = renderer.render(node) + return JBHtmlPane( + JBHtmlPaneStyleConfiguration { + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + customStyleSheetProvider { sheet() } + }, + ).apply { + isEditable = false + isOpaque = opts.opaque + background = opts.background + text = "$body" + addHyperlinkListener { e -> + if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener + val href = e.description ?: return@addHyperlinkListener + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point + dispatch(MdView.LinkEvent(href, pt)) + } + } + } + + private fun codeBlock(text: String, lang: String?): JComponent { + val opts = opts() + val field = runCatching { CodeField(file(lang), opts, text) }.getOrElse { err -> + LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) + textArea(text, opts) + } + return JBScrollPane(field).apply { + border = JBUI.Borders.empty() + isOpaque = opts.opaque + background = opts.preBg + viewport.background = opts.preBg + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + } + + private fun textArea(text: String, opts: MdStyle) = JBTextArea(text.trimEnd('\n')).apply { + isEditable = false + lineWrap = false + isOpaque = opts.opaque + background = opts.preBg + foreground = opts.preFg + font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + border = JBUI.Borders.empty(6, 8) + } + + private inner class CodeField(file: FileType, opts: MdStyle, value: String) : + com.intellij.ui.EditorTextField(ProjectManager.getInstance().defaultProject, file) { + init { + setFontInheritedFromLAF(false) + font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + text = value.trimEnd('\n') + isViewer = true + addSettingsProvider { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = false + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + } + } + } + + private fun file(lang: String?): FileType { + val key = lang?.trim()?.substringBefore(' ')?.lowercase().orEmpty() + val ext = when (key) { + "kt", "kotlin" -> "kt" + "js", "javascript" -> "js" + "ts", "typescript" -> "ts" + "tsx" -> "tsx" + "java" -> "java" + "py", "python" -> "py" + "sh", "bash", "shell" -> "sh" + "json" -> "json" + "xml" -> "xml" + "html" -> "html" + "css" -> "css" + "md", "markdown" -> "md" + else -> "" + } + if (ext.isEmpty()) return PlainTextFileType.INSTANCE + return FileTypeManager.getInstance().getFileTypeByExtension(ext) + } + + private fun dispatch(event: MdView.LinkEvent) { + for (l in listeners) l.onLink(event) + } + + private fun sheet(): StyleSheet { + val sheet = StyleSheet() + val rules = overrideSheet() + if (rules.isEmpty()) return sheet + try { + sheet.addRule(rules) + } catch (err: Exception) { + LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) + } + return sheet + } + + private fun opts(): MdStyle { + val base = MdCommon.defaults(style) + return base.copy( + font = fontOverride ?: base.font, + foreground = foregroundOverride ?: base.foreground, + background = backgroundOverride ?: base.background, + linkColor = linkColorOverride ?: base.linkColor, + codeBg = codeBgOverride ?: base.codeBg, + preBg = preBgOverride ?: base.preBg, + preFg = preFgOverride ?: base.preFg, + codeFont = codeFontOverride ?: base.codeFont, + quoteBorder = quoteBorderOverride ?: base.quoteBorder, + quoteFg = quoteFgOverride ?: base.quoteFg, + tableBorder = tableBorderOverride ?: base.tableBorder, + opaque = opaqueState, + ) + } + + private inner class Visitor : AbstractVisitor() { + override fun visit(document: Document) { + visitChildren(document) + } + + override fun visit(code: FencedCodeBlock) { + addBlock(codeBlock(code.literal, code.info)) + } + + override fun visit(code: IndentedCodeBlock) { + addBlock(codeBlock(code.literal, null)) + } + + public override fun visitChildren(parent: Node) { + var child = parent.firstChild + while (child != null) { + val next = child.next + if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) + if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) addBlock(htmlBlock(child)) + child = next + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt index e407fb61b49..edf175d53dc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt @@ -1,6 +1,9 @@ package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -18,6 +21,7 @@ class SessionRecoveryTest : SessionControllerTestBase() { super.setUp() // Set a pre-existing session in the fake API rpc.session = rpc.session.copy(id = "ses_test") + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) } fun `test pending permission is recovered on history load`() { @@ -30,7 +34,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -50,7 +53,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -70,7 +72,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -96,7 +97,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -110,7 +110,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { fun `test busy status is seeded from statuses map`() { rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -131,7 +130,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { next = 5000L, )) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -154,7 +152,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { requestID = "req_xyz", )) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -171,7 +168,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { fun `test idle status in map leaves controller in Idle`() { rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle")) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -187,7 +183,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { fun `test missing status entry leaves controller in Idle`() { rpc.statuses.value = emptyMap() - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -211,7 +206,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() @@ -243,7 +237,6 @@ class SessionRecoveryTest : SessionControllerTestBase() { ) ) - appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt new file mode 100644 index 00000000000..51d5d68f165 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdViewFactoryTest : BasePlatformTestCase() { + fun `test create returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.create(), MdViewHybrid::class.java) + } + + fun `test hybrid returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.hybrid(), MdViewHybrid::class.java) + } + + fun `test html returns html pane renderer`() { + assertInstanceOf(MdViewFactory.html(), MdViewHtmlPane::class.java) + } + + fun `test create applies supplied session style`() { + val style = SessionStyle.create(family = "Courier New", size = 22) + val view = MdViewFactory.create(style) + + assertEquals("Courier New", view.font.name) + assertEquals(22, view.font.size) + assertEquals("Courier New", view.codeFont) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt new file mode 100644 index 00000000000..15a14ed852b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -0,0 +1,77 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.SessionStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class MdViewHybridTest : BasePlatformTestCase() { + private lateinit var view: MdView + + override fun setUp() { + super.setUp() + view = MdViewFactory.hybrid() + } + + fun `test set stores source`() { + view.set("hello **world**") + assertEquals("hello **world**", view.markdown()) + } + + fun `test append renders accumulated source`() { + view.append("hello ") + view.append("**world**") + assertEquals("hello **world**", view.markdown()) + assertTrue(view.html().contains("")) + } + + fun `test fenced code block creates horizontal scroll pane`() { + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + } + + fun `test clear resets source and components`() { + view.set("```\ncode\n```") + view.clear() + + assertEquals("", view.markdown()) + assertTrue(scrolls().isEmpty()) + } + + fun `test applyStyle updates current and future blocks`() { + val style = SessionStyle.create(family = "Courier New", size = 21) + + view.applyStyle(style) + view.set("hello") + + assertEquals("Courier New", view.font.name) + assertEquals(21, view.font.size) + assertTrue(view.overrideSheet().contains("Courier New")) + assertTrue(view.overrideSheet().contains("21pt")) + } + + fun `test resetStyles keeps content rendered`() { + view.set("hello **world**") + view.font = view.font.deriveFont(25f) + + view.resetStyles() + + assertEquals("hello **world**", view.markdown()) + assertTrue(view.html().contains("")) + } + + fun `test link listener receives simulated link`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + + view.simulateLink("https://example.com") + + assertEquals("https://example.com", received.single().href) + } + + private fun scrolls(): List = (view.component as JPanel).components.filterIsInstance() +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt index 5eaac3e666f..f5d698cf10e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt @@ -6,7 +6,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase class MdViewLoggingTest : BasePlatformTestCase() { fun `test invalid font family does not throw while building override sheet`() { - val view = MdView.html() + val view = MdViewFactory.html() view.codeFont = "broken'font" view.set("`x`") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 025fb5787c7..3ca087bb135 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -1,11 +1,12 @@ package ai.kilocode.client.ui.md +import ai.kilocode.client.session.ui.SessionStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Font /** - * Tests for [MdView] created via [MdView.html]. + * Tests for the fallback HTML [MdView]. * * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that * JBHtmlPane initialisation works correctly. @@ -17,7 +18,7 @@ class MdViewTest : BasePlatformTestCase() { override fun setUp() { super.setUp() - view = MdView.html() + view = MdViewFactory.html() } // ---- set ---- @@ -211,10 +212,13 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("Done.")) } - // ---- style overrides (empty by default) ---- + // ---- style overrides ---- - fun `test no overrides produces empty override sheet`() { - assertEquals("", view.overrideSheet()) + fun `test override sheet includes session style defaults`() { + val style = SessionStyle.current() + + assertTrue(view.overrideSheet().contains(style.editorFamily)) + assertTrue(view.overrideSheet().contains("${style.editorSize}pt")) } // ---- style overrides appear in override sheet when set ---- @@ -309,16 +313,16 @@ class MdViewTest : BasePlatformTestCase() { assertEquals("", view.markdown()) } - // ---- default codeFont uses editor font placeholder ---- + // ---- default codeFont uses session style ---- - fun `test default codeFont is editor font placeholder`() { - // When no codeFont override is set, the getter returns the editor font placeholder - assertTrue(view.codeFont.contains("_Editor")) + fun `test default codeFont is session editor font`() { + val style = SessionStyle.current() + + assertEquals(style.editorFamily, view.codeFont) } - fun `test default override sheet is empty before any set`() { - // Only overrides appear in the sheet; editor defaults are handled by JBHtmlPane - assertEquals("", view.overrideSheet()) + fun `test default override sheet includes session style before any set`() { + assertTrue(view.overrideSheet().contains(SessionStyle.current().editorFamily)) } // ---- background sets component background ---- @@ -384,7 +388,7 @@ class MdViewTest : BasePlatformTestCase() { fun `test resetStyles clears foreground override`() { view.foreground = Color.RED view.resetStyles() - assertEquals("", view.overrideSheet()) + assertFalse(view.overrideSheet().contains("#ff0000")) } fun `test resetStyles clears all overrides`() { @@ -399,7 +403,9 @@ class MdViewTest : BasePlatformTestCase() { view.tableBorder = Color.YELLOW view.font = Font("Arial", Font.PLAIN, 18) view.resetStyles() - assertEquals("", view.overrideSheet()) + val sheet = view.overrideSheet() + assertFalse(sheet.contains("Arial")) + assertTrue(sheet.contains(SessionStyle.current().editorFamily)) } fun `test resetStyles restores opaque to true`() { From 347526e9fdd9d81e7130c493809b5c82f4924240 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 16:32:52 +0000 Subject: [PATCH 002/201] chore: generate --- .../opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index ce10d7d54a8..f640fbee901 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -22,9 +22,7 @@ function View(props: { api: TuiPluginApi }) { - - {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} - + {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} {(item) => ( From 817cb076a8075314644aa330ce1598cb31c468f3 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Sat, 9 May 2026 18:45:44 +0200 Subject: [PATCH 003/201] fix(acp): include tool image attachments in updates (#25128) --- packages/opencode/src/acp/agent.ts | 130 +++++++------ .../test/acp/event-subscription.test.ts | 179 +++++++++++++++++- 2 files changed, 246 insertions(+), 63 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 1d941c6b92e..867b830cf27 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -347,33 +347,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -413,10 +387,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((error) => { @@ -860,33 +831,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -926,10 +871,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((err) => { @@ -1655,6 +1597,70 @@ function toLocations(toolName: string, input: Record): { path: stri } } +function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { + if (part.state.status !== "completed") return [] + + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + content.push(...imageContents(part.state.attachments ?? [])) + return content +} + +function completedToolRawOutput(part: ToolPart) { + if (part.state.status !== "completed") return {} + return { + output: part.state.output, + metadata: part.state.metadata, + ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), + } +} + +function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { + return attachments.flatMap((attachment): ToolCallContent[] => { + const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? attachment.mime + if (!mime.startsWith("image/")) return [] + const data = match?.[2] + if (data === undefined) return [] + return [ + { + type: "content" as const, + content: { + type: "image" as const, + mimeType: mime, + data, + }, + }, + ] + }) +} + async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { const sdk = config.sdk const configured = config.defaultModel diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 2722757ab9e..d0fb2e994d1 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test" import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2" +import type { + Event, + EventMessagePartUpdated, + ToolStateCompleted, + ToolStatePending, + ToolStateRunning, +} from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" @@ -36,6 +42,14 @@ function isToolCallUpdate( return update.sessionUpdate === "tool_call_update" } +function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) { + return sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter(isToolCallUpdate) + .find((u) => u.toolCallId === callID && u.status === "completed") +} + function toolEvent( sessionId: string, cwd: string, @@ -78,6 +92,45 @@ function toolEvent( return { directory: cwd, payload } } +function completedToolEvent( + sessionId: string, + cwd: string, + opts: { + callID: string + tool: string + input: Record + output: string + attachments?: ToolStateCompleted["attachments"] + }, +): GlobalEventEnvelope { + const state: ToolStateCompleted = { + status: "completed", + input: opts.input, + output: opts.output, + title: opts.tool, + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + ...(opts.attachments && { attachments: opts.attachments }), + } + const payload: EventMessagePartUpdated = { + type: "message.part.updated", + properties: { + sessionID: sessionId, + time: Date.now(), + part: { + id: `part_${opts.callID}`, + sessionID: sessionId, + messageID: `msg_${opts.callID}`, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }, + } + return { directory: cwd, payload } +} + function createEventStream() { const queue: GlobalEventEnvelope[] = [] const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] @@ -616,6 +669,130 @@ describe("acp.agent event subscription", () => { }) }) + test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("image-data").toString("base64") + + controller.push( + completedToolEvent(sessionId, cwd, { + callID: "call_image", + tool: "read", + input: { filePath: "/tmp/image.png" }, + output: "Image read successfully", + attachments: [ + { + id: "part_image", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "image/png", + filename: "image.png", + url: `data:image/png;base64,${data}`, + }, + { + id: "part_text", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,Zm9v", + }, + ], + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image read successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/png", data }, + }) + expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false) + expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2) + + stop() + }, + }) + }) + + test("replays completed tool image attachments as ACP tool content blocks", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, sessionUpdates, stop, sdk } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("replay-image").toString("base64") + + sdk.session.messages = async () => ({ + data: [ + { + info: { + role: "assistant", + sessionID: sessionId, + }, + parts: [ + { + id: "part_replay", + sessionID: sessionId, + messageID: "msg_replay", + type: "tool", + callID: "call_replay_image", + tool: "webfetch", + state: { + status: "completed", + input: { url: "https://example.com/image.png" }, + output: "Image fetched successfully", + title: "webfetch", + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + attachments: [ + { + id: "part_replay_image", + sessionID: sessionId, + messageID: "msg_replay", + type: "file", + mime: "image/jpeg", + filename: "image.jpg", + url: `data:image/jpeg;base64,${data}`, + }, + ], + }, + }, + ], + }, + ], + }) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image fetched successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/jpeg", data }, + }) + + stop() + }, + }) + }) + test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() await WithInstance.provide({ From 780bbb0f3bb8616150b4981d1f7cbdeec2aab6f4 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 9 May 2026 16:54:43 +0000 Subject: [PATCH 004/201] sync release versions for v1.14.42 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 3f22ce5b479..2d216eed605 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.41", + "version": "1.14.42", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "0.0.0", + "version": "1.14.42", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.25", + "version": "1.14.42", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.41", + "version": "1.14.42", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 600c011b6b3..8a6cd1a5549 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.41", + "version": "1.14.42", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index b31488f1553..56bdac4e979 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4ca29eb4c7d..f49cd43d7be 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.41", + "version": "1.14.42", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 7e1d77d7dc5..14f04d7c577 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.41", + "version": "1.14.42", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 34ddd073f0e..e62e1b4a5fd 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 995ab18ee53..ceb227b2f16 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.41", + "version": "1.14.42", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 49e35c5db8d..0b149d8064c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7f964f511a8..9d682c736f9 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.41", + "version": "1.14.42", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8b4850c885b..427f6518157 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.41" +version = "1.14.42" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 70812ab10a5..b05c7b01268 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.41", + "version": "1.14.42", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ee4865b4751..b2726b339c9 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.0.0", + "version": "1.14.42", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 3a616f40937..97b7818fe50 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.25", + "version": "1.14.42", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3126914416c..c90ae269306 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.41", + "version": "1.14.42", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 91afa3c8830..79e1d5b5e5b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 2959cba2dd0..c3d491bde02 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 34175d66a22..ea3a6adcfc6 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index fc065be9eff..d626fc221bd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 252c81a295c..49dd1bcd4f5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.41", + "version": "1.14.42", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 3eaca42fb77..b1d14c48e5c 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.41", + "version": "1.14.42", "publisher": "sst-dev", "repository": { "type": "git", From b1cd25de3d24e0494c513355721048644dd804d9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 9 May 2026 12:08:00 -0500 Subject: [PATCH 005/201] ignore: fix typerrs on dev (#26544) --- packages/opencode/test/acp/event-subscription.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index d0fb2e994d1..791b5c578fa 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -8,7 +8,6 @@ import type { ToolStatePending, ToolStateRunning, } from "@opencode-ai/sdk/v2" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" @@ -113,6 +112,7 @@ function completedToolEvent( ...(opts.attachments && { attachments: opts.attachments }), } const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, type: "message.part.updated", properties: { sessionID: sessionId, @@ -671,7 +671,7 @@ describe("acp.agent event subscription", () => { test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -728,7 +728,7 @@ describe("acp.agent event subscription", () => { test("replays completed tool image attachments as ACP tool content blocks", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, sessionUpdates, stop, sdk } = createFakeAgent() From 27fa297a42cc7c25c6b34823f22d2f16e43256d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 13:40:46 -0400 Subject: [PATCH 006/201] fix(server): keep provider lists JSON-safe (#26550) --- packages/opencode/src/provider/provider.ts | 12 ++- .../instance/httpapi/handlers/config.ts | 2 +- .../instance/httpapi/handlers/provider.ts | 2 +- .../instance/httpapi/middleware/error.ts | 26 ------ .../test/server/httpapi-provider.test.ts | 88 +++++++++++++++++++ 5 files changed, 101 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4013dcee36e..d9b7fac2fa0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -935,6 +935,16 @@ export const ConfigProvidersResult = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ConfigProvidersResult = Types.DeepMutable> +export function toPublicInfo(provider: Info): Info { + return JSON.parse( + JSON.stringify(provider, (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }), + ) +} + export function defaultModelIDs }>(providers: Record) { return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) } @@ -1299,7 +1309,7 @@ const layer: Layer.Layer< const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - database[plugin.auth!.provider], + toPublicInfo(database[plugin.auth!.provider]), ), ) const opts = options ?? {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 753ba031380..3d0e8a06c09 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -24,7 +24,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h const providers = Effect.fn("ConfigHttpApi.providers")(function* () { const providers = yield* providerSvc.list() return { - providers: Object.values(providers), + providers: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), } }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 15049fcc553..7027e666cad 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -30,7 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" connected, ) return { - all: Object.values(providers), + all: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), connected: Object.keys(connected), } diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 5a018f16da6..6f3c33a647a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -6,39 +6,13 @@ import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError } from "effect/unstable/httpapi" -import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError" const log = Log.create({ service: "server" }) -function badRequestResponse() { - return HttpServerResponse.jsonUnsafe( - { - data: {}, - errors: [], - success: false, - }, - { status: 400 }, - ) -} - -function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) { - if (response.status !== 400 || response.body._tag !== "Empty") return response - return badRequestResponse() -} - // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => effect.pipe( - Effect.catch((error) => { - if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse()) - return Effect.fail(error) - }), - Effect.map(normalizeEmptyBadRequest), Effect.catchCause((cause) => { - const schemaError = cause.reasons.filter(Cause.isDieReason).find((reason) => HttpApiSchemaError.is(reason.defect)) - if (schemaError) return Effect.succeed(badRequestResponse()) - const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false if (HttpServerError.isHttpServerError(reason.defect)) return false diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 12262e30ed5..d44ff4cd609 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -21,6 +21,22 @@ function app() { return Server.Default().app } +function providerListHasFetch(list: unknown) { + if (!Array.isArray(list)) return false + return list.some((item: unknown) => { + if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false + if (item.id !== "google") return false + if (typeof item.options !== "object" || item.options === null) return false + return "fetch" in item.options + }) +} + +function hasProviderWithFetch(input: unknown, key: "all" | "providers") { + if (typeof input !== "object" || input === null) return false + if (key === "all") return "all" in input && providerListHasFetch(input.all) + return "providers" in input && providerListHasFetch(input.providers) +} + function requestAuthorize(input: { app: ReturnType providerID: string @@ -76,6 +92,39 @@ function writeProviderAuthPlugin(dir: string) { }) } +function writeFunctionOptionsPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-function-options.ts"), + [ + "export default {", + ' id: "test.provider-function-options",', + " server: async () => ({", + " auth: {", + ' provider: "google",', + " loader: async (_getAuth, provider) => {", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return {", + ' apiKey: "",', + " fetch: async (input, init) => fetch(input, init),", + " }", + " },", + " methods: [{ type: 'api', label: 'API key' }],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function withProviderProject(self: (dir: string) => Effect.Effect) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -136,4 +185,43 @@ describe("provider HttpApi", () => { }), ), ) + + it.live("serves provider lists when auth loaders add runtime fetch options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + const previous = process.env.OPENCODE_AUTH_CONTENT + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeFunctionOptionsPlugin(dir) + yield* Effect.sync(() => { + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, + }) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT + if (previous !== undefined) process.env.OPENCODE_AUTH_CONTENT = previous + }), + ) + const headers = { "x-opencode-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderWithFetch(providerBody, "all")).toBe(false) + expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + }), + ) }) From e22144806f74d998188259bdeea2eb3400b61db0 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 9 May 2026 18:06:16 +0000 Subject: [PATCH 007/201] sync release versions for v1.14.43 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 2d216eed605..ab87d3b029c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.42", + "version": "1.14.43", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.42", + "version": "1.14.43", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 8a6cd1a5549..2150e0694f5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.42", + "version": "1.14.43", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 56bdac4e979..aa6e859d716 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f49cd43d7be..b41240fe573 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.42", + "version": "1.14.43", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 14f04d7c577..b7c3aa2a987 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.42", + "version": "1.14.43", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index e62e1b4a5fd..6ebaf2daad5 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index ceb227b2f16..1f843e40d38 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.42", + "version": "1.14.43", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0b149d8064c..f051bd50d04 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 9d682c736f9..7f33ad4e1c4 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.42", + "version": "1.14.43", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 427f6518157..8af387c4bfa 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.42" +version = "1.14.43" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index b05c7b01268..6c15ccc3063 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.42", + "version": "1.14.43", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index b2726b339c9..b8c0d58832f 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.42", + "version": "1.14.43", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 97b7818fe50..d3ac5357e08 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.42", + "version": "1.14.43", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c90ae269306..9a9f53f57c4 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.42", + "version": "1.14.43", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 79e1d5b5e5b..9344f0488ab 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c3d491bde02..d6443f3b2bb 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index ea3a6adcfc6..92aedbe0633 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index d626fc221bd..325916d4569 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 49dd1bcd4f5..92f690f2504 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.42", + "version": "1.14.43", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index b1d14c48e5c..cb8db945ea8 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.42", + "version": "1.14.43", "publisher": "sst-dev", "repository": { "type": "git", From 57efec44290b41e95511870f50d598905c50781f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 14:18:55 -0400 Subject: [PATCH 008/201] fix(storage): default workspace time migration (#26556) --- .../migration.sql | 2 +- .../storage/workspace-time-migration.test.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/storage/workspace-time-migration.test.ts diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql index bdeed6bce18..c865526a88e 100644 --- a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql +++ b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql @@ -1 +1 @@ -ALTER TABLE `workspace` ADD `time_used` integer NOT NULL; \ No newline at end of file +ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0; diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts new file mode 100644 index 00000000000..2d30646976f --- /dev/null +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" +import { migrate } from "drizzle-orm/bun-sqlite/migrator" +import { readFileSync, readdirSync } from "fs" +import path from "path" + +const target = "20260507164347_add_workspace_time" + +function migrations() { + return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + timestamp: Number(entry.name.split("_")[0]), + sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"), + })) + .sort((a, b) => a.timestamp - b.timestamp) +} + +describe("workspace time migration", () => { + test("migrates existing workspace rows", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + const entries = migrations() + const index = entries.findIndex((entry) => entry.name === target) + + expect(index).toBeGreaterThan(0) + + migrate(db, entries.slice(0, index)) + sqlite.run( + "INSERT INTO project (id, worktree, vcs, name, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["project_1", "/tmp/project", "git", "project", 1, 1, "[]"], + ) + sqlite.run( + "INSERT INTO workspace (id, type, name, branch, directory, extra, project_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["workspace_1", "local", "main", "main", "/tmp/project", null, "project_1"], + ) + + expect(() => migrate(db, entries.slice(index))).not.toThrow() + expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 }) + }) +}) From ebe9dcf27ef9366a8a94f0984db0cc3c2622539e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 14:39:25 -0400 Subject: [PATCH 009/201] fix(server): return validation error bodies --- .../src/server/routes/instance/httpapi/api.ts | 3 +++ .../httpapi/middleware/schema-error.ts | 21 +++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 3 +++ 3 files changed, 27 insertions(+) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index bdd917e4e8d..4c6e46a4556 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" +import { SchemaErrorMiddleware } from "./middleware/schema-error" // SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) @@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads() export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) + .middleware(SchemaErrorMiddleware) .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") @@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) + .middleware(SchemaErrorMiddleware) export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts new file mode 100644 index 00000000000..9eecde3e64e --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/ExperimentalHttpApiSchemaError", +) {} + +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { + if (error.kind === "Body") return Effect.fail(error) + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + { + data: {}, + errors: [{ kind: error.kind, message: error.cause.message }], + success: false, + }, + { status: 400 }, + ), + ) +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 495497ecb4d..a64a9b90833 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" +import { schemaErrorLayer } from "./middleware/schema-error" export const context = Context.makeUnsafe(new Map()) @@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), + Layer.provide(schemaErrorLayer), Layer.provide(httpApiAuthLayer), ) const instanceRouterLayer = authorizationRouterMiddleware @@ -142,6 +144,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( tuiHandlers, workspaceHandlers, ]), + Layer.provide(schemaErrorLayer), ) const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) From 6fea0178eb44ad7d6ccb0eccabb241bb82b36916 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 14:50:45 -0400 Subject: [PATCH 010/201] fix(server): defer validation error body change --- .../src/server/routes/instance/httpapi/api.ts | 3 --- .../httpapi/middleware/schema-error.ts | 21 ------------------- .../server/routes/instance/httpapi/server.ts | 3 --- .../opencode/test/server/httpapi-sync.test.ts | 2 +- 4 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 4c6e46a4556..bdd917e4e8d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -21,7 +21,6 @@ import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" -import { SchemaErrorMiddleware } from "./middleware/schema-error" // SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) @@ -30,7 +29,6 @@ const SyncEventSchemas = SyncEvent.effectPayloads() export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) - .middleware(SchemaErrorMiddleware) .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") @@ -49,7 +47,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) - .middleware(SchemaErrorMiddleware) export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts deleted file mode 100644 index 9eecde3e64e..00000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Effect } from "effect" -import { HttpServerResponse } from "effect/unstable/http" -import { HttpApiMiddleware } from "effect/unstable/httpapi" - -export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( - "@opencode/ExperimentalHttpApiSchemaError", -) {} - -export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { - if (error.kind === "Body") return Effect.fail(error) - return Effect.succeed( - HttpServerResponse.jsonUnsafe( - { - data: {}, - errors: [{ kind: error.kind, message: error.cause.message }], - success: false, - }, - { status: 400 }, - ), - ) -}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index a64a9b90833..495497ecb4d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -84,7 +84,6 @@ import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" -import { schemaErrorLayer } from "./middleware/schema-error" export const context = Context.makeUnsafe(new Map()) @@ -115,7 +114,6 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), - Layer.provide(schemaErrorLayer), Layer.provide(httpApiAuthLayer), ) const instanceRouterLayer = authorizationRouterMiddleware @@ -144,7 +142,6 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( tuiHandlers, workspaceHandlers, ]), - Layer.provide(schemaErrorLayer), ) const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index c5ee6378427..cd626c28f4f 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -122,7 +122,7 @@ describe("sync HttpApi", () => { } }) - test("returns structured validation errors", async () => { + test.todo("returns structured validation errors", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await ExperimentalHttpApiServer.webHandler().handler( new Request(`http://localhost${SyncPaths.history}`, { From 19abadaf276675cd64d5a747f8e495fede47a42a Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 9 May 2026 19:33:26 +0000 Subject: [PATCH 011/201] sync release versions for v1.14.44 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index ab87d3b029c..17b0caccd1a 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.43", + "version": "1.14.44", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.43", + "version": "1.14.44", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 2150e0694f5..0b18298ba6d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.43", + "version": "1.14.44", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index aa6e859d716..8ce88d49481 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index b41240fe573..93655393026 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.43", + "version": "1.14.44", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b7c3aa2a987..3e1daabd528 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.43", + "version": "1.14.44", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 6ebaf2daad5..127a352de55 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 1f843e40d38..fc7a8f84d19 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.43", + "version": "1.14.44", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f051bd50d04..355db03fe4a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7f33ad4e1c4..ecf6d05191f 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.43", + "version": "1.14.44", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8af387c4bfa..8dcd84b37fd 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.43" +version = "1.14.44" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 6c15ccc3063..6f871cb8c66 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.43", + "version": "1.14.44", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index b8c0d58832f..f4874d763a4 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.43", + "version": "1.14.44", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index d3ac5357e08..e983f19e80e 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.43", + "version": "1.14.44", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9a9f53f57c4..cd58a663f81 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.43", + "version": "1.14.44", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 9344f0488ab..10f9b6014d7 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index d6443f3b2bb..10b306ea31e 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 92aedbe0633..813df5b04ef 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 325916d4569..7c92c131075 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 92f690f2504..6b923e2331e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.43", + "version": "1.14.44", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index cb8db945ea8..b39d872b513 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.43", + "version": "1.14.44", "publisher": "sst-dev", "repository": { "type": "git", From 5e49029e70567148ebf9b86f2b71cc7721d58c78 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 15:47:07 -0400 Subject: [PATCH 012/201] fix(provider): isolate plugin model mutations (#26561) Co-authored-by: Developer --- packages/opencode/src/provider/provider.ts | 2 +- .../test/server/httpapi-provider.test.ts | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d9b7fac2fa0..4184f96b2c1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1162,7 +1162,7 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) + const next = await models(toPublicInfo(provider), { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index d44ff4cd609..68db6663d2c 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -37,6 +37,38 @@ function hasProviderWithFetch(input: unknown, key: "all" | "providers") { return "providers" in input && providerListHasFetch(input.providers) } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function providerList(input: unknown, key: "all" | "providers") { + if (!isRecord(input)) return [] + if (!Array.isArray(input[key])) return [] + return input[key] +} + +function providerByID(input: unknown, key: "all" | "providers", id: string) { + return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id) +} + +function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider) || !isRecord(provider.models)) return false + return Object.values(provider.models).some((model) => { + if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false + return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some( + (cost) => typeof cost === "number" && cost > 0, + ) + }) +} + +function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider)) return false + if (provider.name === "mutated-provider") return true + return isRecord(provider.options) && provider.options.mutatedByPlugin === true +} + function requestAuthorize(input: { app: ReturnType providerID: string @@ -125,6 +157,40 @@ function writeFunctionOptionsPlugin(dir: string) { }) } +function writeProviderModelsMutationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"), + [ + "export default {", + ' id: "test.provider-models-mutation",', + " server: async () => ({", + " provider: {", + ' id: "google",', + " models: async (provider) => {", + " const models = Object.fromEntries(", + " Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),", + " )", + ' provider.name = "mutated-provider"', + " provider.options = { ...provider.options, mutatedByPlugin: true }", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return models", + " },", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function withProviderProject(self: (dir: string) => Effect.Effect) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -222,6 +288,37 @@ describe("provider HttpApi", () => { const configBody = yield* Effect.promise(() => configResponse.json()) expect(hasProviderWithFetch(providerBody, "all")).toBe(false) expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) + }), + ) + + it.live("keeps provider.models hook input mutations out of provider state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeProviderModelsMutationPlugin(dir) + + const headers = { "x-opencode-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) + expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) }), ) }) From dcdbdb218fafc4ce2d0495f90bf022a08fc51d80 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 9 May 2026 15:51:09 -0400 Subject: [PATCH 013/201] Move schema utilities into core (#26565) --- packages/{opencode/src/util => core/src}/effect-zod.ts | 0 packages/{opencode/src/util => core/src}/schema.ts | 0 packages/opencode/specs/effect/migration.md | 4 ++-- packages/opencode/specs/effect/schema.md | 6 +++--- packages/opencode/src/agent/agent.ts | 4 ++-- packages/opencode/src/auth/index.ts | 4 ++-- packages/opencode/src/cli/cmd/tui/event.ts | 2 +- packages/opencode/src/command/index.ts | 4 ++-- packages/opencode/src/config/agent.ts | 4 ++-- packages/opencode/src/config/command.ts | 4 ++-- packages/opencode/src/config/config.ts | 6 +++--- packages/opencode/src/config/console-state.ts | 4 ++-- packages/opencode/src/config/formatter.ts | 4 ++-- packages/opencode/src/config/layout.ts | 4 ++-- packages/opencode/src/config/lsp.ts | 4 ++-- packages/opencode/src/config/mcp.ts | 4 ++-- packages/opencode/src/config/model-id.ts | 4 ++-- packages/opencode/src/config/parse.ts | 2 +- packages/opencode/src/config/permission.ts | 4 ++-- packages/opencode/src/config/plugin.ts | 4 ++-- packages/opencode/src/config/provider.ts | 4 ++-- packages/opencode/src/config/reference.ts | 4 ++-- packages/opencode/src/config/server.ts | 4 ++-- packages/opencode/src/config/skills.ts | 4 ++-- packages/opencode/src/control-plane/schema.ts | 4 ++-- packages/opencode/src/control-plane/types.ts | 2 +- packages/opencode/src/file/index.ts | 4 ++-- packages/opencode/src/file/ripgrep.ts | 4 ++-- packages/opencode/src/format/index.ts | 4 ++-- packages/opencode/src/lsp/lsp.ts | 4 ++-- packages/opencode/src/mcp/index.ts | 4 ++-- packages/opencode/src/permission/index.ts | 4 ++-- packages/opencode/src/permission/schema.ts | 4 ++-- packages/opencode/src/project/project.ts | 4 ++-- packages/opencode/src/project/schema.ts | 4 ++-- packages/opencode/src/project/vcs.ts | 4 ++-- packages/opencode/src/provider/auth.ts | 4 ++-- packages/opencode/src/provider/provider.ts | 4 ++-- packages/opencode/src/provider/schema.ts | 4 ++-- packages/opencode/src/pty/index.ts | 4 ++-- packages/opencode/src/pty/schema.ts | 4 ++-- packages/opencode/src/pty/ticket.ts | 2 +- packages/opencode/src/question/index.ts | 4 ++-- packages/opencode/src/question/schema.ts | 4 ++-- .../server/routes/instance/httpapi/groups/experimental.ts | 2 +- .../src/server/routes/instance/httpapi/groups/sync.ts | 2 +- .../server/routes/instance/httpapi/handlers/experimental.ts | 2 +- packages/opencode/src/session/message-v2.ts | 4 ++-- packages/opencode/src/session/message.ts | 4 ++-- packages/opencode/src/session/prompt.ts | 6 +++--- packages/opencode/src/session/revert.ts | 4 ++-- packages/opencode/src/session/schema.ts | 4 ++-- packages/opencode/src/session/session.ts | 4 ++-- packages/opencode/src/session/status.ts | 4 ++-- packages/opencode/src/session/summary.ts | 4 ++-- packages/opencode/src/session/todo.ts | 4 ++-- packages/opencode/src/skill/index.ts | 4 ++-- packages/opencode/src/snapshot/index.ts | 4 ++-- packages/opencode/src/storage/storage.ts | 2 +- packages/opencode/src/sync/index.ts | 2 +- packages/opencode/src/sync/schema.ts | 4 ++-- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/schema.ts | 4 ++-- packages/opencode/src/tool/shell/prompt.ts | 2 +- packages/opencode/src/util/named-schema-error.ts | 2 +- packages/opencode/src/v2/auth.ts | 2 +- packages/opencode/src/v2/event.ts | 2 +- packages/opencode/src/v2/model.ts | 2 +- packages/opencode/src/v2/session-event.ts | 2 +- packages/opencode/src/v2/session.ts | 2 +- packages/opencode/test/tool/parameters.test.ts | 2 +- packages/opencode/test/util/effect-zod.test.ts | 2 +- 73 files changed, 125 insertions(+), 125 deletions(-) rename packages/{opencode/src/util => core/src}/effect-zod.ts (100%) rename packages/{opencode/src/util => core/src}/schema.ts (100%) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/core/src/effect-zod.ts similarity index 100% rename from packages/opencode/src/util/effect-zod.ts rename to packages/core/src/effect-zod.ts diff --git a/packages/opencode/src/util/schema.ts b/packages/core/src/schema.ts similarity index 100% rename from packages/opencode/src/util/schema.ts rename to packages/core/src/schema.ts diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 947eef5a156..01af9da6ce7 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -59,10 +59,10 @@ Rules: ## Schema → Zod interop -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: +When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: ```ts -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index e755457e614..e20605c3bc3 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims. Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors. Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@/util/effect-zod`. +schema via `@opencode-ai/core/effect-zod`. The long-term driver is `specs/effect/http-api.md` — once the HTTP server moves to `@effect/platform`, every Schema-first DTO can flow through @@ -97,7 +97,7 @@ creating a parallel schema source of truth. ## Escape hatches -The walker in `@/util/effect-zod` exposes two explicit escape hatches for +The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for cases the pure-Schema path cannot express. Each one stays in the codebase only as long as its upstream or local dependency requires it — inline comments document when each can be deleted. @@ -389,7 +389,7 @@ piecewise. ## Notes -- Use `@/util/effect-zod` for all Schema → Zod conversion. +- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 85846824123..a5876ac3477 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -24,8 +24,8 @@ import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" -import { zod } from "@/util/effect-zod" -import { withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" type ReferenceEntry = NonNullable[string] type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 3d6a0d91d0d..f7c6319357a 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index fbe5ce7f9ff..bebb1fc6aa6 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 140d2b8a7a6..e26c4068b1d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index e72f6587284..94c8d8fe008 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent" import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 4d0fec6872f..c611f3c198d 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -5,8 +5,8 @@ import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5b7aa16ff11..073001e1916 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -22,8 +22,8 @@ import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath } from "../project/instance-context" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -307,7 +307,7 @@ export const Info = Schema.Struct({ })), ) -// Uses the shared `DeepMutable` from `@/util/schema`. See the definition +// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from // effect-smol (the upstream version collapses `unknown` to `{}`). export type Info = DeepMutable> & { diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 0d4f20df917..485e334167a 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 8c1f09a2471..222a750057c 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,8 +1,8 @@ export * as ConfigFormatter from "./formatter" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Entry = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts index 49c34b66398..a5299ea9557 100644 --- a/packages/opencode/src/config/layout.ts +++ b/packages/opencode/src/config/layout.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Layout = Schema.Literals(["auto", "stretch"]) .annotate({ identifier: "LayoutConfig" }) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 1cf93177e41..accfbee3b2f 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,8 +1,8 @@ export * as ConfigLSP from "./lsp" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as LSPServer from "../lsp/server" export const Disabled = Schema.Struct({ diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index fc31ba356fa..bb4fd88f04b 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 3ad9e035ce6..26fa2e0b346 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" // The original Zod schema carried an external $ref pointing at the models.dev // JSON schema. That external reference is not a named SDK component — it is a diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index 9351047894a..f964ed4e150 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -3,7 +3,7 @@ export * as ConfigParse from "./parse" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" import z from "zod" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { InvalidError, JsonError } from "./error" type ZodSchema = z.ZodType diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 5326b007bd9..8c5f8549967 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,7 +1,7 @@ export * as ConfigPermission from "./permission" import { Schema, SchemaGetter } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Action = Schema.Literals(["ask", "allow", "deny"]) .annotate({ identifier: "PermissionActionConfig" }) diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 9667dbb59ab..b1e3ec6f42e 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import path from "path" export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 7821bca5a93..239a0164d8d 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index eea3d998c1e..36a8faff7ed 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,8 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const Git = Schema.Struct({ repository: Schema.String.annotate({ diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 3f136982694..159ba0ce5a9 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Server = Schema.Struct({ port: Schema.optional(PositiveInt).annotate({ diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f29d854f50a..f707e922eee 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ paths: Schema.optional(Schema.Array(Schema.String)).annotate({ diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 5a0850a2495..dd4c325490f 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( Schema.brand("WorkspaceID"), diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ba5a778fac6..e78d728e040 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,7 +1,7 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4dd6a3ae7a6..52f2b8486d4 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -14,8 +14,8 @@ import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ path: Schema.String, diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 27fd5f2323d..8459dd9ac14 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index a61eb7be29a..c9ab433f118 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -7,8 +7,8 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import * as Formatter from "./formatter" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "format" }) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 5110eccbf80..a647dc099f2 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod, ZodOverride } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fe718023885..20e8c912e11 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -30,8 +30,8 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod as effectZod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod as effectZod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d93670709e1..f4bd2e2cc1a 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index e8bdb2ea20e..725030935db 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index a2c1a097b10..25feb657c14 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index 7708b8de1ee..c6cff94fde6 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 02173453dbf..28050e86f77 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod, zodObject } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, zodObject } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 9b2ca33c319..135df6fecff 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,9 +1,9 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4184f96b2c1..18974ea6460 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" @@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index ea3cac34249..757b70f3ff1 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index ade4b5d02ec..85e0840cb7e 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,8 +10,8 @@ import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "pty" }) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 6b4d779f261..0f1d6996df5 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index b5e5747c513..0978e520837 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -3,7 +3,7 @@ export * as PtyTicket from "./ticket" import { WorkspaceID } from "@/control-plane/schema" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { PtyID } from "@/pty/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" const DEFAULT_TTL = Duration.seconds(60) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d52f353da98..c041462ad40 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -3,9 +3,9 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { QuestionID } from "./schema" const log = Log.create({ service: "question" }) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 7dade9cdfa9..c18eca3e23e 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class QuestionID extends Newtype()( "QuestionID", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 3488d2616c3..0c11c6c472f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -3,7 +3,7 @@ import { MCP } from "@/mcp" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { Worktree } from "@/worktree" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 442e6565547..1603c29c4cc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,4 @@ -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index fa91b441178..55272fc2f29 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -6,7 +6,7 @@ import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@/util/effect-zod" +import * as EffectZod from "@opencode-ai/core/effect-zod" import { Worktree } from "@/worktree" import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 85f6124505f..23a01a17043 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,8 +23,8 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" -import { zod, ZodOverride } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 9d67c48686c..12952a87b94 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fef8c438366..1d5e4055c86 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,6 @@ import path from "path" import os from "os" -import * as EffectZod from "@/util/effect-zod" +import * as EffectZod from "@opencode-ai/core/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" @@ -46,8 +46,8 @@ import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index abf7c3441fe..12c81180ebe 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -4,8 +4,8 @@ import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" import { SyncEvent } from "../sync" import * as Log from "@opencode-ai/core/util/log" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 487cbcd34a7..d0e6cd4cb7b 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( Schema.brand("SessionID"), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5c938ff6938..510d82a7313 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -37,8 +37,8 @@ import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "session" }) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index abe51bcd610..1dd36ec53a9 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -2,8 +2,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f1709d5a2f0..d5e52b91e99 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,8 +2,8 @@ import { Effect, Layer, Context, Schema } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 32a83704647..9b7daf7f0c9 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,8 +1,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" import { Database } from "@/storage/db" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 696ab887a72..3000406ebc5 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -2,8 +2,8 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context, Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ea30f5afc7c..6acd74763ee 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,8 +10,8 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod } from "@opencode-ai/core/effect-zod" export const Patch = Schema.Struct({ hash: Schema.String, diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5b2df1e899a..bc4d8b8f17f 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 05d9727891e..931f5e84c88 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -9,7 +9,7 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e714b86ae0c..e4e2e75b73a 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index bf01fc7d2d5..4b5ba5efedc 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema, Scope } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c8a91c1de15..2a5a64033db 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -17,7 +17,7 @@ import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@/util/effect-zod" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 9ce7bece2b0..b6c263a4ce3 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 45c637863a6..f26e364b612 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { ShellID } from "./id" diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d9b92a23cc8..d87e1dcdb55 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import z from "zod" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts index 1cc443974d8..0ac6223a66b 100644 --- a/packages/opencode/src/v2/auth.ts +++ b/packages/opencode/src/v2/auth.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" import { Identifier } from "@opencode-ai/core/util/identifier" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index fde8d4326f4..8d0562eba37 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,6 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Flag } from "@opencode-ai/core/flag/flag" import * as Schema from "effect/Schema" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts index db66199a59b..a3ee8821074 100644 --- a/packages/opencode/src/v2/model.ts +++ b/packages/opencode/src/v2/model.ts @@ -1,4 +1,4 @@ -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 7c768bd551a..f439b1f8418 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "./event" import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index bb86f039b23..b3da6009f69 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -10,7 +10,7 @@ import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" -import { optionalOmitUndefined } from "@/util/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9f6a0617eda..17af7b983e4 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "../../src/util/effect-zod" +import { toJsonSchema } from "@opencode-ai/core/effect-zod" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 70cd8f0e647..ab3923d8e0a 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) From 9a8b54fe622566e1b65401bb3e03fd4798dddef2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 9 May 2026 21:56:49 +0200 Subject: [PATCH 014/201] Plugin command API shim (#26564) --- .../opencode/specs/v2/tui-command-shim.md | 67 +++++++++++ .../opencode/src/cli/cmd/tui/plugin/api.tsx | 3 + .../src/cli/cmd/tui/plugin/command-shim.ts | 109 ++++++++++++++++++ .../src/cli/cmd/tui/plugin/runtime.ts | 3 + packages/plugin/src/tui.ts | 43 +++++++ 5 files changed, 225 insertions(+) create mode 100644 packages/opencode/specs/v2/tui-command-shim.md create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts diff --git a/packages/opencode/specs/v2/tui-command-shim.md b/packages/opencode/specs/v2/tui-command-shim.md new file mode 100644 index 00000000000..5afade2a966 --- /dev/null +++ b/packages/opencode/specs/v2/tui-command-shim.md @@ -0,0 +1,67 @@ +# TUI Command Shim Removal + +Problem: + +- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization +- v2 should expose only the keymap command API +- tests and fixtures should not encode legacy command behavior as expected behavior + +## Remove Public Types + +In `packages/plugin/src/tui.ts`, remove: + +- `TuiCommand` +- `TuiCommandApi` +- `TuiPluginApi.command` + +Keep `api.keymap` as the only TUI command registration and execution surface. + +## Remove Runtime Shim + +Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`. + +In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `createTuiApi(...)` + +In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `pluginApi(...)` + +## Migration Target + +Plugin authors should replace old calls with keymap calls: + +```ts +api.keymap.registerLayer({ + commands: [ + { + name: "plugin.command", + title: "Plugin Command", + namespace: "palette", + slashName: "plugin", + run() { + api.ui.dialog.clear() + }, + }, + ], + bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }], +}) +``` + +Direct replacements: + +- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })` +- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)` +- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")` +- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure + +## Verification + +After removal, run from package directories: + +- `bun typecheck` in `packages/plugin` +- `bun typecheck` in `packages/opencode` +- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 7b7ce0bbb53..6015150ec2e 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Keymap from "../keymap" +import { createCommandShim } from "./command-shim" type RouteEntry = { key: symbol @@ -200,6 +201,8 @@ export function createTuiApi(input: Input): TuiPluginApi { } return { app: appApi(), + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds), keys: { formatSequence(parts) { return Keymap.formatKeySequence(parts, input.tuiConfig) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts new file mode 100644 index 00000000000..61eb833fe7f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts @@ -0,0 +1,109 @@ +// Legacy `api.command` bridge for v1 plugins; remove in v2. +import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui" +import { TuiKeybind } from "../config/keybind" +import type { DialogContext } from "../ui/dialog" + +const COMMAND_PALETTE_SHOW = "command.palette.show" +const warned = new Set() + +type Warn = (api: string, replacement: string) => void +type LegacyDialog = TuiPluginApi["ui"]["dialog"] +type CommandShimDialog = DialogContext | LegacyDialog +type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"] + +function warnCommandShim(api: string, replacement: string) { + // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2. + console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement }) +} + +function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog { + if (!("stack" in dialog)) return dialog + return { + replace(render, onClose) { + dialog.replace(render, onClose) + }, + clear() { + dialog.clear() + }, + setSize(size) { + dialog.setSize(size) + }, + get size() { + return dialog.size + }, + get depth() { + return dialog.stack.length + }, + get open() { + return dialog.stack.length > 0 + }, + } +} + +function warnOnce(api: string, replacement: string, warn: Warn) { + if (warned.has(api)) return + warned.add(api) + warn(api, replacement) +} + +function toCommand(item: TuiCommand, dialog: LegacyDialog) { + return { + namespace: "palette", + name: item.value, + title: item.title, + desc: item.description, + category: item.category, + suggested: item.suggested, + hidden: item.hidden, + enabled: item.enabled, + slashName: item.slash?.name, + slashAliases: item.slash?.aliases, + run() { + return item.onSelect?.(dialog) + }, + } +} + +function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) { + return commands.flatMap((item) => + item.keybind + ? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + ? keybinds + .get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + .map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title })) + : [ + { + key: item.keybind, + cmd: item.value, + desc: item.title, + }, + ] + : [], + ) +} + +export function createCommandShim( + keymap: TuiPluginApi["keymap"], + dialog: CommandShimDialog, + keybinds: LegacyKeybinds, +): TuiPluginApi["command"] { + const shimDialog = createCommandShimDialog(dialog) + return { + register(cb) { + warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim) + const commands = cb() + return keymap.registerLayer({ + commands: commands.map((item) => toCommand(item, shimDialog)), + bindings: toBindings(commands, keybinds), + }) + }, + trigger(value) { + warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim) + keymap.dispatchCommand(value) + }, + show() { + warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim) + keymap.dispatchCommand(COMMAND_PALETTE_SHOW) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 91ccaaaa015..64961b20f7f 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +import { createCommandShim } from "./command-shim" ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) @@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, keymap, route, diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 13bc17f66b5..851b0476e51 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -77,6 +77,42 @@ export type TuiKeys = { export type TuiKeymap = Keymap +/** + * Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. + */ +export type TuiCommand = { + title: string + value: string + description?: string + category?: string + keybind?: string + suggested?: boolean + hidden?: boolean + enabled?: boolean + slash?: { + name: string + aliases?: string[] + } + onSelect?: (dialog?: TuiDialogStack) => void | Promise +} + +/** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ +export type TuiCommandApi = { + /** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */ + register: (cb: () => TuiCommand[]) => () => void + /** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */ + trigger: (value: string) => void + /** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */ + show: () => void +} + export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" onClose: () => void @@ -461,6 +497,13 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp + /** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ + command?: TuiCommandApi keys: TuiKeys keymap: TuiKeymap route: { From 6c2dfd2f52099d952cfe78613d55370b9fb9fefd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 15:58:03 -0400 Subject: [PATCH 015/201] fix(tui): guard messages.data in session.sync against undefined (#26566) Co-authored-by: Developer --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 6 +- .../test/cli/cmd/tui/sync-fixture.tsx | 120 +++++++++++++++++ .../cmd/tui/sync-undefined-messages.test.tsx | 47 +++++++ .../opencode/test/cli/cmd/tui/sync.test.tsx | 121 +----------------- 4 files changed, 172 insertions(+), 122 deletions(-) create mode 100644 packages/opencode/test/cli/cmd/tui/sync-fixture.tsx create mode 100644 packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 24609dd81e4..e4c2824d413 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -526,10 +526,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + const infos: typeof draft.message[string] = [] + for (const message of messages.data ?? []) { + infos.push(message.info) draft.part[message.info.id] = message.parts } + draft.message[sessionID] = infos draft.session_diff[sessionID] = diff.data ?? [] }), ) diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx new file mode 100644 index 00000000000..d9ecdbe9d57 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -0,0 +1,120 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" + +export const worktree = "/tmp/opencode" +export const directory = `${worktree}/packages/opencode` + +export async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +export function json(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, + }) +} + +export function eventSource(): EventSource { + return { subscribe: async () => () => {} } +} + +type FetchHandler = (url: URL) => Response | Promise | undefined + +export function createFetch(override?: FetchHandler) { + const session = [] as URL[] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/session") session.push(url) + + const overridden = await override?.(url) + if (overridden) return overridden + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +type Ctx = { kv: ReturnType; sync: ReturnType } + +export async function mount(override?: FetchHandler) { + const calls = createFetch(override) + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const ctx: Ctx = { kv: useKV(), sync: useSync() } + onMount(() => { + sync = ctx.sync + kv = ctx.kv + done() + }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} diff --git a/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx new file mode 100644 index 00000000000..5fb7ece94dc --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx @@ -0,0 +1,47 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Reproducer for #26560 — TUI crashes with + * `TypeError: undefined is not an object (evaluating 'f.data.map')` + * when entering a session whose messages endpoint returns a non-2xx. + * The failure path is `sync.tsx#sync.session.sync` reading + * `messages.data!` while the SDK leaves `data` undefined on error. + */ +import { describe, expect, test } from "bun:test" +import { Global } from "@opencode-ai/core/global" +import { tmpdir } from "../../../fixture/fixture" +import { directory, json, mount } from "./sync-fixture" + +const sessionID = "ses_undef" + +describe("tui sync (#26560)", () => { + test("entering a session whose messages endpoint errors does not crash sync", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const sessionPayload = { + id: sessionID, + title: "broken", + time: { created: 0, updated: 0 }, + version: "1.14.42", + directory, + project_id: "proj_test", + } + const { app, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(sessionPayload) + if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 }) + if (url.pathname === `/session/${sessionID}/todo`) return json([]) + if (url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === "/session") return json([sessionPayload]) + return undefined + }) + + try { + await expect(sync.session.sync(sessionID)).resolves.toBeUndefined() + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 993484d3cac..f67257f6ceb 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -1,127 +1,8 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import { onMount } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" -import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" -import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" -import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" -import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" import { tmpdir } from "../../../fixture/fixture" - -const worktree = "/tmp/opencode" -const directory = `${worktree}/packages/opencode` - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function json(data: unknown) { - return new Response(JSON.stringify(data), { - headers: { "content-type": "application/json" }, - }) -} - -function eventSource(): EventSource { - return { - subscribe: async () => () => {}, - } -} - -function createFetch() { - const session = [] as URL[] - const fetch = (async (input: RequestInfo | URL) => { - const url = new URL(input instanceof Request ? input.url : String(input)) - if (url.pathname === "/session") session.push(url) - - switch (url.pathname) { - case "/agent": - case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": - case "/formatter": - case "/lsp": - return json([]) - case "/config": - case "/experimental/resource": - case "/mcp": - case "/provider/auth": - case "/session/status": - return json({}) - case "/config/providers": - return json({ providers: {}, default: {} }) - case "/experimental/console": - return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) - case "/path": - return json({ home: "", state: "", config: "", worktree, directory }) - case "/project/current": - return json({ id: "proj_test" }) - case "/provider": - return json({ all: [], default: {}, connected: [] }) - case "/session": - return json([]) - case "/vcs": - return json({ branch: "main" }) - } - - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - - return { fetch, session } -} - -async function mount() { - const calls = createFetch() - let sync!: ReturnType - let kv!: ReturnType - let done!: () => void - const ready = new Promise((resolve) => { - done = resolve - }) - - const app = await testRender(() => ( - - - - - - - { - sync = ctx.sync - kv = ctx.kv - done() - }} - /> - - - - - - - )) - - await ready - await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } -} - -function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { - const kv = useKV() - const sync = useSync() - - onMount(() => { - props.onReady({ kv, sync }) - }) - - return -} +import { mount } from "./sync-fixture" describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { From ad79f3e0cf3780db924c03c5c203ccefff695237 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 19:59:00 +0000 Subject: [PATCH 016/201] chore: generate --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e4c2824d413..25ae4e2736c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -526,7 +526,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - const infos: typeof draft.message[string] = [] + const infos: (typeof draft.message)[string] = [] for (const message of messages.data ?? []) { infos.push(message.info) draft.part[message.info.id] = message.parts From 77da433e0aacaa991123723b8c5f79c6464eb98b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:02:35 -0400 Subject: [PATCH 017/201] fix(session): accept routing params in message list (#26569) Co-authored-by: Developer --- .../routes/instance/httpapi/groups/session.ts | 7 +++++- .../test/server/session-messages.test.ts | 25 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 967cc802062..5dd8f8fabc2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -25,8 +25,12 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) -export const ListQuery = Schema.Struct({ +const WorkspaceRoutingQuery = { directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} +export const ListQuery = Schema.Struct({ + ...WorkspaceRoutingQuery, scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), @@ -36,6 +40,7 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQuery, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e3c5e83136c..f5ee5bdcb0e 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" @@ -165,4 +164,28 @@ describe("session messages endpoint", () => { }), ) }) + + test("accepts directory query used by workspace routing", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 1) + const app = Server.Default().app + + const res = await app.request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + + await svc.remove(session.id) + }, + }), + ) + }) }) From 8602937a37b9a9c8f1bf4b4ea7b52a2f95f6646b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:19:06 -0400 Subject: [PATCH 018/201] test(session): cover workspace-routed messages (#26576) --- packages/opencode/test/server/httpapi-session.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 96ddf8fcce9..d646b35fcbb 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -364,8 +364,15 @@ describe("session HttpApi", () => { headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, body: JSON.stringify({ title: "workspace session" }), }) + const messages = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, + { + headers: { "x-opencode-directory": tmp.path }, + }, + ) expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) expect( yield* Effect.sync(() => Database.use((db) => From d62442bb5d12642b24610260b67437e4cdd9fd83 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sat, 9 May 2026 22:23:28 +0200 Subject: [PATCH 019/201] fix(sessions): allow optional patch field in diff for migrated sessions (#26574) --- packages/opencode/src/cli/cmd/export.ts | 5 +++-- packages/opencode/src/snapshot/index.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index bf73ce941e4..6a64d0e0575 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,11 +23,12 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) { +function diff(kind: string, diffs: { file: string; patch?: string }[] | undefined) { return diffs?.map((item, i) => ({ ...item, file: redact(`${kind}-file`, String(i), item.file), - patch: redact(`${kind}-patch`, String(i), item.patch), + patch: + item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 6acd74763ee..05d234e9b81 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -21,7 +21,7 @@ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ file: Schema.String, - patch: Schema.String, + patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 220278b8c28..6a0ba210278 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -120,7 +120,7 @@ export type PermissionRequest = { export type SnapshotFileDiff = { file: string - patch: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" From 480aa8b23b430875f28c78574743f4f82c0f2f85 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 20:24:28 +0000 Subject: [PATCH 020/201] chore: generate --- packages/opencode/src/cli/cmd/export.ts | 3 +-- packages/sdk/openapi.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 6a64d0e0575..d1f9cd8d7d4 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -27,8 +27,7 @@ function diff(kind: string, diffs: { file: string; patch?: string }[] | undefine return diffs?.map((item, i) => ({ ...item, file: redact(`${kind}-file`, String(i), item.file), - patch: - item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), + patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e19ead9b327..127d48a58c4 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9120,7 +9120,7 @@ "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["file", "additions", "deletions"], "additionalProperties": false }, "ProviderAuthError": { From 805af011c9d74668a58160399d38e8fb446ebdaf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:30:31 -0400 Subject: [PATCH 021/201] test(session): regression test for #26574 + mirror loosening on Vcs.FileDiff (#26578) Co-authored-by: Developer --- packages/opencode/src/project/vcs.ts | 5 +- packages/opencode/src/snapshot/index.ts | 3 + .../server/session-diff-missing-patch.test.ts | 79 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/session-diff-missing-patch.test.ts diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 28050e86f77..21ee882c41f 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -230,7 +230,10 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, - patch: Schema.String, + // Mirrors Snapshot.FileDiff (see #26574). The current producer always + // populates patch, but loosening matches the sibling schema so a + // future code path that omits it can't crash /instance/vcs/diff. + patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 05d234e9b81..8c8fd9156a8 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -21,6 +21,9 @@ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ file: Schema.String, + // Optional because legacy/imported `summary_diffs` on disk may omit + // the patch text (see #26574). Required-Schema rejected the whole + // /session//diff response and broke session loading on Desktop. patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts new file mode 100644 index 00000000000..e85632dbf22 --- /dev/null +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -0,0 +1,79 @@ +/** + * Regression test for the same bug class as #26574 (sibling of #26566 and + * #26553). The Desktop app calls GET /session//diff; before #26574 + * the response was Schema-encoded against `Snapshot.FileDiff` with + * `patch: Schema.String` (required), so any session whose stored + * `summary_diffs` had a row without `patch` returned HTTP 400 and the + * session never loaded. + * + * This test inserts a session row with a missing-patch diff entry and + * asserts that GET /session//diff returns 200 with the row intact. + */ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Server } from "@/server/server" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { Storage } from "@/storage/storage" +import { WithInstance } from "@/project/with-instance" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function pathFor(template: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) +} + +describe("session diff with missing patch (#26574)", () => { + it.live("GET /session//diff returns 200 when summary_diffs row has no patch", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })), + (t) => Effect.promise(() => t[Symbol.asyncDispose]()), + ) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Effect.runPromise( + Effect.provide(Session.Service.use((s) => s.create({ title: "missing-patch" })), Session.defaultLayer), + ) + + // Mimic legacy/imported on-disk shape: a diff entry with no + // `patch` text. Pre-fix the typed response encoder rejects + // this and returns 400. + await Effect.runPromise( + Effect.provide( + Storage.Service.use((s) => + s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), + ), + Storage.defaultLayer, + ), + ) + + const headers = { "x-opencode-directory": tmp.path } + const response = await Server.Default().app.request( + pathFor(SessionPaths.diff, { sessionID: session.id }), + { headers }, + ) + expect(response.status).toBe(200) + const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }> + expect(body).toHaveLength(1) + expect(body[0]?.file).toBe("legacy.txt") + expect(body[0]?.additions).toBe(1) + expect(body[0]?.patch).toBeUndefined() + }, + }), + ) + }), + ) +}) From 5fa5d876fce2536c79c4492c71d64dd4e4755e7d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 20:31:32 +0000 Subject: [PATCH 022/201] chore: generate --- .../test/server/session-diff-missing-patch.test.ts | 12 +++++++----- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index e85632dbf22..5f27a4e2fde 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -45,7 +45,10 @@ describe("session diff with missing patch (#26574)", () => { directory: tmp.path, fn: async () => { const session = await Effect.runPromise( - Effect.provide(Session.Service.use((s) => s.create({ title: "missing-patch" })), Session.defaultLayer), + Effect.provide( + Session.Service.use((s) => s.create({ title: "missing-patch" })), + Session.defaultLayer, + ), ) // Mimic legacy/imported on-disk shape: a diff entry with no @@ -61,10 +64,9 @@ describe("session diff with missing patch (#26574)", () => { ) const headers = { "x-opencode-directory": tmp.path } - const response = await Server.Default().app.request( - pathFor(SessionPaths.diff, { sessionID: session.id }), - { headers }, - ) + const response = await Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), { + headers, + }) expect(response.status).toBe(200) const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }> expect(body).toHaveLength(1) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6a0ba210278..5a79ae26616 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1520,7 +1520,7 @@ export type VcsFileStatus = { export type VcsFileDiff = { file: string - patch: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 127d48a58c4..32c9a0922a9 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -13039,7 +13039,7 @@ "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["file", "additions", "deletions"], "additionalProperties": false }, "VcsApplyError": { From d373c562f2fd34ea38924a18161faa88e46592ab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:44:24 -0400 Subject: [PATCH 023/201] fix(session): accept legacy summary diffs (#26579) Co-authored-by: Developer --- .../src/pages/session/session-side-panel.tsx | 11 +++++-- packages/opencode/src/cli/cmd/export.ts | 4 +-- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 4 ++- packages/opencode/src/session/summary.ts | 1 + packages/opencode/src/snapshot/index.ts | 6 ++-- .../test/server/httpapi-session.test.ts | 30 +++++++++++++++++++ .../test/session/schema-decoding.test.ts | 20 +++++++++++++ .../test/session/snapshot-tool-race.test.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/ui/src/components/session-diff.ts | 3 +- packages/ui/src/components/session-review.tsx | 9 ++++-- packages/ui/src/components/session-turn.tsx | 11 +++++-- 12 files changed, 88 insertions(+), 15 deletions(-) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 99197f0a703..66f5269bf9f 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff + +function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { + return typeof value.file === "string" +} + export function SessionSidePanel(props: { canReview: () => boolean diffs: () => (SnapshotFileDiff | VcsFileDiff)[] @@ -70,7 +76,8 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) + const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -81,7 +88,7 @@ export function SessionSidePanel(props: { const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map() - for (const diff of props.diffs()) { + for (const diff of diffs()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index d1f9cd8d7d4..9eb1faffea7 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,10 +23,10 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; patch?: string }[] | undefined) { +function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) { return diffs?.map((item, i) => ({ ...item, - file: redact(`${kind}-file`, String(i), item.file), + file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file), patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 6015150ec2e..54059f4a2da 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -148,7 +148,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.session.length }, diff(sessionID) { - return sync.data.session_diff[sessionID] ?? [] + return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => + item.file === undefined ? [] : [{ ...item, file: item.file }], + ) }, todo(sessionID) { return sync.data.todo[sessionID] ?? [] diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d5e52b91e99..e39bd85e9ac 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -134,6 +134,7 @@ export const layer = Layer.effect( .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) const next = diffs.map((item) => { + if (item.file === undefined) return item const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 8c8fd9156a8..f54794a8a95 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -20,10 +20,10 @@ export const Patch = Schema.Struct({ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ - file: Schema.String, // Optional because legacy/imported `summary_diffs` on disk may omit - // the patch text (see #26574). Required-Schema rejected the whole - // /session//diff response and broke session loading on Desktop. + // file details and patch text. Required Schema rejected the whole + // session response and broke session loading on Desktop. + file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d646b35fcbb..24c845183de 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -297,6 +297,36 @@ describe("session HttpApi", () => { ), ) + it.live( + "serves sessions with migrated summary diffs missing file details", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const session = yield* createSession(tmp.path, { title: "legacy diff" }) + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ), + ) + + const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) + }), + ), + ) + it.live( "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index bee2184e5b3..e9628ce49fe 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -83,6 +83,26 @@ describe("Session.Info", () => { expect(Session.Info.zod.parse(input)).toEqual(input) }) + test("accepts migrated summary diffs without file details", () => { + const input = { + id: sessionID, + slug: "legacy-diff", + projectID, + directory: "/tmp/proj", + title: "Legacy diff", + version: "0.1.0", + summary: { + additions: 1, + deletions: 0, + files: 1, + diffs: [{ additions: 1, deletions: 0 }], + }, + time: { created: 1, updated: 2 }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + test("rejects unbranded session id", () => { const bad = { id: "not-a-session-id" } as unknown expect(() => decode(bad)).toThrow() diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 82b88a72fd6..671f62145c5 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -238,7 +238,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget - let diff: Array<{ file: string }> = [] + let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { diff = yield* summary.diff({ sessionID: session.id }) if (diff.length > 0) break diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5a79ae26616..0d8ab611790 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -119,7 +119,7 @@ export type PermissionRequest = { } export type SnapshotFileDiff = { - file: string + file?: string patch?: string additions: number deletions: number diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index bd6bed88d8f..60dcffd83d2 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -12,7 +12,8 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff +type SnapshotDiff = SnapshotFileDiff & { file: string } +type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff export type ViewDiff = { file: string diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 949402f4392..1089587ee1d 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -62,7 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } +type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } function diff(value: unknown): value is ReviewDiff { @@ -108,7 +113,7 @@ export interface SessionReviewProps { classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element - diffs: ReviewDiff[] + diffs: RawReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise lineCommentMention?: LineCommentEditorProps["mention"] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index b35f718ef04..a39b9a7f643 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -90,6 +90,12 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +type SummaryDiff = SnapshotFileDiff & { file: string } + +function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" +} + const hidden = new Set(["todowrite"]) function partState(part: PartType, showReasoningSummaries: boolean) { @@ -169,7 +175,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: SnapshotFileDiff[] = [] + const emptyDiffs: SummaryDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -238,7 +244,8 @@ export function SessionTurn( const seen = new Set() return files - .reduceRight((result, diff) => { + .reduceRight((result, diff) => { + if (!summaryDiff(diff)) return result if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff) From c61ab518865638192f091ee9b915a427ceaf37ec Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 20:45:29 +0000 Subject: [PATCH 024/201] chore: generate --- packages/sdk/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 32c9a0922a9..270749dc1af 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9120,7 +9120,7 @@ "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "additions", "deletions"], + "required": ["additions", "deletions"], "additionalProperties": false }, "ProviderAuthError": { From 43b51f09d095aadb3d851fde51031369be30d23f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:50:30 -0400 Subject: [PATCH 025/201] fix(httpapi): align runtime query schemas with workspace routing params (#26581) Co-authored-by: Developer --- .../instance/httpapi/groups/experimental.ts | 5 +- .../routes/instance/httpapi/groups/file.ts | 6 +- .../instance/httpapi/groups/instance.ts | 3 +- .../routes/instance/httpapi/groups/pty.ts | 7 +- .../routes/instance/httpapi/groups/session.ts | 15 +- .../httpapi/middleware/workspace-routing.ts | 12 +- .../server/httpapi-query-schema-drift.test.ts | 134 ++++++++++++++++++ 7 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-query-schema-drift.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 0c11c6c472f..d2f3c65ad21 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -8,7 +8,7 @@ import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ @@ -43,6 +43,7 @@ const ToolListItem = Schema.Struct({ }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) export const ToolListQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, provider: ProviderID, model: ModelID, }) @@ -55,7 +56,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( ) const WorktreeList = Schema.Array(Schema.String) export const SessionListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), cursor: Schema.optional(Schema.NumberFromString), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383e..fe0b0b617cb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -5,18 +5,21 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, path: Schema.String, }) export const FindTextQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, pattern: Schema.String, }) export const FindFileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -26,6 +29,7 @@ export const FindFileQuery = Schema.Struct({ }) export const FindSymbolQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index f2b0504a05f..5c45cd5c155 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -8,7 +8,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -20,6 +20,7 @@ const PathInfo = Schema.Struct({ }).annotate({ identifier: "Path" }) export const VcsDiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, mode: Vcs.Mode, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index ad513e0ad41..17f5890f1ec 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -5,13 +5,16 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" export const Params = Schema.Struct({ ptyID: PtyID }) -export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const CursorQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + cursor: Schema.optional(Schema.String), +}) export const ShellItem = Schema.Struct({ path: Schema.String, name: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 5dd8f8fabc2..f1dc3697b94 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -14,7 +14,7 @@ import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -25,12 +25,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) -const WorkspaceRoutingQuery = { - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), -} export const ListQuery = Schema.Struct({ - ...WorkspaceRoutingQuery, + ...WorkspaceRoutingQueryFields, scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), @@ -38,9 +34,12 @@ export const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const DiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), +}) export const MessagesQuery = Schema.Struct({ - ...WorkspaceRoutingQuery, + ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 8ec9f74860d..fd5d534bbc3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -9,11 +9,21 @@ import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Data, Effect, Layer } from "effect" +import { Context, Data, Effect, Layer, Schema } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" +// Query fields this middleware reads from the URL. Spread into every +// endpoint query schema in groups that apply WorkspaceRoutingMiddleware, +// otherwise HttpApi rejects requests carrying these params with 400. +// HttpApiMiddleware in effect-smol cannot declare query params today — +// remove this once upstream supports middleware-declared query schemas. +export const WorkspaceRoutingQueryFields = { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} + type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 00000000000..68daeca1e96 --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { SessionID } from "../../src/session/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +function app() { + return Server.Default().app +} + +function request(url: string, init?: RequestInit) { + return Effect.promise(async () => app().request(url, init)) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +// Regression for the "OpenAPI advertises ?directory&workspace, runtime +// rejects them" drift class. Each affected route must accept both params +// without 400. +describe("httpapi query schema drift", () => { + const routingParams = (dir: string) => + `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` + + const expectNotSchemaRejection = (status: number, url: string) => { + expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) + } + + it.live( + "session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "session messages accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/file accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find/file?query=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/text accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find?pattern=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file read accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/file?path=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental tool list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "vcs diff accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) +}) From bd1029b19fd7e360359c0bc3b5f5161fe769fb7e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:46:10 -0400 Subject: [PATCH 026/201] test(server): cover HttpApi context inheritance Adds regression coverage for request context inheritance in promptAsync and explicit context provisioning in stream bodies. --- .../httpapi-promptasync-context.test.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/opencode/test/server/httpapi-promptasync-context.test.ts diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts new file mode 100644 index 00000000000..106e5496867 --- /dev/null +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -0,0 +1,191 @@ +// Regression coverage for issue #26526's claim that promptAsync's +// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not — +// forkIn preserves Context.Reference values via standard fiber inheritance. +// +// The companion claim that the streaming prompt handler "captures and +// provides" those services is true and load-bearing: Stream.fromEffect's +// body runs detached from the request fiber's context, so the explicit +// Effect.provideService calls there are required, not defensive duplication. + +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Scope } from "effect" +import * as Stream from "effect/Stream" +import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { mkdir } from "node:fs/promises" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + InstanceLayer.layer, + Project.defaultLayer, + workspaceLayer, + ), +) + +const instanceContextTestLayer = instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const setupWorkspace = (kind: string) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + const projectID = yield* Project.Service.use((svc) => + svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)), + ) + registerAdapter(projectID, kind, localAdapter(dir)) + const workspace = yield* Workspace.Service.use((svc) => + svc.create({ type: kind, branch: null, extra: null, projectID }), + ) + return { dir, workspace } + }) + +type Capture = { directory?: string; workspaceID?: string } + +const captureInstance = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { directory: instance?.directory, workspaceID } satisfies Capture +}) + +describe("HttpApi handler context inheritance", () => { + // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits + // the request's Context — including InstanceRef and WorkspaceRef provided + // by InstanceContextMiddleware — without any explicit re-provide. + it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-fork") + const capture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/fork-probe", + Effect.gen(function* () { + const scope = yield* Scope.Scope + yield* Effect.gen(function* () { + yield* Deferred.succeed(capture, yield* captureInstance) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return HttpServerResponse.empty({ status: 204 }) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.post( + `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`, + ) + expect(response.status).toBe(204) + + const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds")) + expect(observed.directory).toBe(dir) + expect(observed.workspaceID).toBe(workspace.id) + }), + ) + + // Mirrors handlers/session.ts:255 prompt — the streaming handler reads + // InstanceRef/WorkspaceRef in the request fiber and re-provides them to + // the Stream.fromEffect body. This test locks in why the explicit + // provides are required: without them the stream body sees undefined. + it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-stream") + const withoutCapture = yield* Deferred.make() + const withCapture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/stream-probe-without", + Effect.gen(function* () { + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withoutCapture, yield* captureInstance) + return "" + }), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + yield* HttpRouter.add( + "POST", + "/stream-probe-with", + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withCapture, yield* captureInstance) + return "" + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}` + const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`) + yield* responseWithout.text + const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`) + yield* responseWith.text + + const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds")) + expect(without.directory).toBeUndefined() + expect(without.workspaceID).toBeUndefined() + + const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds")) + expect(withProvide.directory).toBe(dir) + expect(withProvide.workspaceID).toBe(workspace.id) + }), + ) +}) From ba9e4b67ed2f14c1043f483b070be58889c8714a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:46:29 -0400 Subject: [PATCH 027/201] fix(tool/read): match permission patterns against worktree-relative path Read permission checks now use the same worktree-relative path basis as edit/write/apply_patch, so configured patterns apply consistently. --- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/test/tool/read.test.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 4b5ba5efedc..7ade166c5f7 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -178,7 +178,7 @@ export const ReadTool = Tool.define( yield* ctx.ask({ permission: "read", - patterns: [filepath], + patterns: [path.relative(instance.worktree, filepath)], always: ["*"], metadata: {}, }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 695d96ec2fe..969364bad9f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -155,11 +155,24 @@ describe("tool.read external_directory permission", () => { yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") expect(read).toBeDefined() - expect(read!.patterns).toEqual([full(target)]) + expect(read!.patterns).toEqual([path.relative(dir, full(target))]) }), ) } + it.live("uses worktree-relative path for read permission so user rules match like edit/write", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "src", "secret.ts"), "shh") + + const { items, next } = asks() + yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns).toEqual([path.join("src", "secret.ts")]) + }), + ) + it.live("asks for directory-scoped external_directory permission when reading external directory", () => Effect.gen(function* () { const outer = yield* tmpdirScoped() From 11363170ca81b07e3f44e1bb4f316e8de05de7d0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:46:43 -0400 Subject: [PATCH 028/201] fix(sdk): wrap thrown error bodies in Error SDK throwOnError paths now convert structured response bodies into real Error instances while preserving the original body and status in cause. --- .../opencode/test/server/httpapi-sdk.test.ts | 10 ++- .../test/server/sdk-error-shape.test.ts | 74 +++++++++++++++++++ packages/sdk/js/src/client.ts | 2 + packages/sdk/js/src/error-interceptor.ts | 51 +++++++++++++ packages/sdk/js/src/v2/client.ts | 21 +----- 5 files changed, 138 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/test/server/sdk-error-shape.test.ts create mode 100644 packages/sdk/js/src/error-interceptor.ts diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 8a179a4dccd..0201f98c25d 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -394,8 +394,16 @@ describe("HttpApi SDK", () => { const missing = yield* capture(() => sdk.session.get({ sessionID })) const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + // Result-tuple path: error body is preserved as-is so existing + // consumers reading `result.error.name` / `JSON.stringify(error)` + // keep working byte-for-byte. expect(missing.error).toEqual(expected) - expect(thrown).toEqual(expected) + // throwOnError path: SDK wraps the body in a real Error with the + // server's message, with the original parsed body preserved under + // `.cause.body`. + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toBe(expected.data.message) + expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected) return { status: missing.status, error: missing.error, diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts new file mode 100644 index 00000000000..30eedc9adb2 --- /dev/null +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -0,0 +1,74 @@ +/** + * Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true` + * path used to throw raw values (empty strings or POJOs from JSON-decoded + * error bodies). The TUI catches those and `e.message`/`e.stack` are + * undefined, so users see `[object Object]` or a blank crash. + * + * Both cases must throw a real `Error` instance with a non-empty `.message` + * extracted from the response body, plus `.status` and `.body` attached. + */ +import { afterEach, describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { Server } from "../../src/server/server" +import * as Log from "@opencode-ai/core/util/log" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createOpencodeClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v2 SDK error shape", () => { + test("404 with NamedError body throws a real Error carrying the server message", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(err.message).toContain("Session not found") + expect(cause.status).toBe(404) + expect(cause.body).toMatchObject({ + name: "NotFoundError", + data: { message: expect.stringContaining("Session not found") }, + }) + }) + + test("400 with empty body throws a real Error naming the status", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + // POST /sync/history with `aggregate: -1` triggers schema validation + // that returns an empty 400 body (verified via plan-mode probe). + await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { status?: number } + expect(err.message.length).toBeGreaterThan(0) + expect(cause.status).toBe(400) + }) +}) diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index 05f46382523..5cf071e7b7a 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "./error-interceptor.js" export { type Config as OpencodeClientConfig, OpencodeClient } function pick(value: string | null, fallback?: string) { @@ -51,5 +52,6 @@ export function createOpencodeClient(config?: Config & { directory?: string }) { const client = createClient(config) client.interceptors.request.use((request) => rewrite(request, config?.directory)) + client.interceptors.error.use(wrapClientError) return new OpencodeClient({ client }) } diff --git a/packages/sdk/js/src/error-interceptor.ts b/packages/sdk/js/src/error-interceptor.ts new file mode 100644 index 00000000000..26407ecfc90 --- /dev/null +++ b/packages/sdk/js/src/error-interceptor.ts @@ -0,0 +1,51 @@ +/** + * Wrap whatever the generated client decoded from a non-2xx error body + * into a real `Error` so downstream formatters (TUI, plugins) get a + * useful `.message` instead of `[object Object]` or blank. The original + * parsed body and status live under `.cause` for callers that need + * structured fields. + * + * Only fires when the caller used `{ throwOnError: true }`. Callers that + * read `result.error` directly (the result-tuple path) get the parsed + * body unchanged so existing field-level reads (`.error.name`, + * `JSON.stringify(error)`, etc.) are byte-for-byte identical to before. + */ +export function wrapClientError( + error: unknown, + response: Response | undefined, + request: Request | undefined, + opts: { throwOnError?: boolean } | undefined, +): unknown { + if (!opts?.throwOnError) return error + if (error instanceof Error) return error + + // NamedError-shaped responses (the common case for opencode 4xx) come + // through as POJOs — extract a useful message first, then wrap. + if (typeof error === "object" && error !== null && Object.keys(error).length > 0) { + const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown } + const message = + (typeof obj.data?.message === "string" && obj.data.message) || + (typeof obj.message === "string" && obj.message) || + (typeof obj.name === "string" && obj.name) || + describe(request, response) + return new Error(message, { cause: { body: error, status: response?.status } }) + } + + if (typeof error === "string" && error.length > 0) { + return new Error(error, { cause: { body: error, status: response?.status } }) + } + + // Empty body / network failure / undefined / null / empty object. + const reason = response ? "(empty response body)" : "network error (no response)" + return new Error(`opencode server ${describe(request, response)}: ${reason}`, { + cause: { body: error, status: response?.status }, + }) +} + +function describe(request: Request | undefined, response: Response | undefined) { + const method = request?.method ?? "?" + const url = request?.url ?? "?" + const status = response?.status + const statusText = response?.statusText + return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}` +} diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 8b49e7f101b..1c8afc0d644 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "../error-interceptor.js" export { type Config as OpencodeClientConfig, OpencodeClient } function pick(value: string | null, fallback?: string, encode?: (value: string) => string) { @@ -84,24 +85,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) - // The generated client falls back to throwing a literal `{}` when the server - // responds with an empty / unparseable error body, which surfaces as a bare - // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so - // downstream formatters get a useful message — but pass through any parsed - // JSON error body unchanged so existing consumers can still inspect fields. - client.interceptors.error.use((error, response, request) => { - const isEmpty = - error === undefined || - error === null || - error === "" || - (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) - if (!isEmpty) return error - const method = request?.method ?? "?" - const url = request?.url ?? "?" - if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) - const status = response.status - const statusText = response.statusText ? " " + response.statusText : "" - return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) - }) + client.interceptors.error.use(wrapClientError) return new OpencodeClient({ client }) } From 29b5b2478703805afc11d8e278fb3427bce3fe54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:46:52 -0400 Subject: [PATCH 029/201] fix(tui): aggregate bootstrap request failures TUI bootstrap now reports all parallel fetch failures together instead of losing sibling failures after the first rejection. --- .../cli/cmd/tui/context/aggregate-failures.ts | 34 +++++++++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 26 +++++--- .../cli/cmd/tui/aggregate-failures.test.ts | 59 +++++++++++++++++++ 3 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts create mode 100644 packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts new file mode 100644 index 00000000000..63b3fb44876 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts @@ -0,0 +1,34 @@ +/** + * Aggregate Promise.allSettled results into a single Error that names every + * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap + * boundaries so a single 4xx doesn't drown its parallel siblings as + * unhandled rejections — every failure surfaces in one labeled message. + */ +export type LabeledSettled = { + name: string + result: PromiseSettledResult +} + +export function aggregateFailures(labeled: LabeledSettled[]): Error | null { + const failed = labeled.filter( + (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected", + ) + if (failed.length === 0) return null + + const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") + const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` + const err = new Error(summary) + err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } + return err +} + +function reasonMessage(reason: unknown): string { + if (reason instanceof Error) return reason.message + if (typeof reason === "string") return reason + if (reason && typeof reason === "object") { + const obj = reason as { message?: unknown; name?: unknown } + if (typeof obj.message === "string") return obj.message + if (typeof obj.name === "string") return obj.name + } + return String(reason) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 25ae4e2736c..afdb05d838c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" +import { aggregateFailures } from "./aggregate-failures" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -391,16 +392,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const blockingRequests: Promise[] = [ - providersPromise, - providerListPromise, - agentsPromise, - configPromise, - projectPromise, - ...(args.continue ? [sessionListPromise] : []), + const blockingRequests: { name: string; promise: Promise }[] = [ + { name: "config.providers", promise: providersPromise }, + { name: "provider.list", promise: providerListPromise }, + { name: "app.agents", promise: agentsPromise }, + { name: "config.get", promise: configPromise }, + { name: "project.sync", promise: projectPromise }, + ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ] - await Promise.all(blockingRequests) + await Promise.allSettled(blockingRequests.map((r) => r.promise)) + .then((settled) => { + // Surface every failed endpoint in one labeled message instead of + // letting the first rejection drown its siblings as unhandled + // rejections. + const failure = aggregateFailures( + blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })), + ) + if (failure) throw failure + }) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts new file mode 100644 index 00000000000..ae591e24fdf --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -0,0 +1,59 @@ +/** + * Regression test for the TUI bootstrap aggregation helper. Replaces the + * pre-fix Promise.all behavior where the first rejection drowned every + * sibling endpoint's failure as an unhandled rejection. + */ +import { describe, expect, test } from "bun:test" +import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures" + +describe("aggregateFailures", () => { + test("returns null when every result is fulfilled", () => { + expect( + aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { name: "providers", result: { status: "fulfilled", value: 2 } }, + ]), + ).toBeNull() + }) + + test("names the failed endpoint when one rejects", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { + name: "providers", + result: { status: "rejected", reason: new Error("Service unavailable") }, + }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("1 of 2") + expect(err!.message).toContain("providers: Service unavailable") + }) + + test("names every failed endpoint when multiple reject", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } }, + { name: "providers", result: { status: "fulfilled", value: 1 } }, + { name: "agents", result: { status: "rejected", reason: { message: "boom" } } }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("2 of 3") + expect(err!.message).toContain("config: 400 Bad Request") + expect(err!.message).toContain("agents: boom") + }) + + test("attaches structured failure list under .cause", () => { + const reason = new Error("nope") + const err = aggregateFailures([ + { name: "providers", result: { status: "rejected", reason } }, + ]) + const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } + expect(cause.failures).toEqual([{ name: "providers", reason }]) + }) + + test("falls back to String() for opaque reasons", () => { + const err = aggregateFailures([ + { name: "x", result: { status: "rejected", reason: 42 } }, + ]) + expect(err!.message).toContain("x: 42") + }) +}) From 818b56dbd0a73e6906b8c62dd9ca9293a13d0bfa Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 22:47:54 +0000 Subject: [PATCH 030/201] chore: generate --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 4 +--- .../opencode/test/cli/cmd/tui/aggregate-failures.test.ts | 8 ++------ .../test/server/httpapi-promptasync-context.test.ts | 4 +--- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index afdb05d838c..0d4cb2e6e28 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -406,9 +406,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ // Surface every failed endpoint in one labeled message instead of // letting the first rejection drown its siblings as unhandled // rejections. - const failure = aggregateFailures( - blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })), - ) + const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] }))) if (failure) throw failure }) .then(async () => { diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts index ae591e24fdf..c9b3551d9a2 100644 --- a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -43,17 +43,13 @@ describe("aggregateFailures", () => { test("attaches structured failure list under .cause", () => { const reason = new Error("nope") - const err = aggregateFailures([ - { name: "providers", result: { status: "rejected", reason } }, - ]) + const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } expect(cause.failures).toEqual([{ name: "providers", reason }]) }) test("falls back to String() for opaque reasons", () => { - const err = aggregateFailures([ - { name: "x", result: { status: "rejected", reason: 42 } }, - ]) + const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }]) expect(err!.message).toContain("x: 42") }) }) diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts index 106e5496867..a7a66ff4f60 100644 --- a/packages/opencode/test/server/httpapi-promptasync-context.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -79,9 +79,7 @@ const setupWorkspace = (kind: string) => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) yield* Project.use.fromDirectory(dir) - const projectID = yield* Project.Service.use((svc) => - svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)), - ) + const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id))) registerAdapter(projectID, kind, localAdapter(dir)) const workspace = yield* Workspace.Service.use((svc) => svc.create({ type: kind, branch: null, extra: null, projectID }), From 00c324829528130b54fd630df2418bd406620c83 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:59:05 -0400 Subject: [PATCH 031/201] fix(config): allow active provider model status (#26592) Co-authored-by: Developer --- packages/opencode/src/config/provider.ts | 2 +- packages/opencode/src/provider/models.ts | 2 +- .../test/server/httpapi-config.test.ts | 37 +++++++++++++++++++ packages/sdk/js/src/gen/types.gen.ts | 4 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 239a0164d8d..e501227ae5e 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -49,7 +49,7 @@ export const Model = Schema.Struct({ }), ), experimental: Schema.optional(Schema.Boolean), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 77e217eb7f8..a2b6ee2a79e 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -71,7 +71,7 @@ export const Model = Schema.Struct({ ), }), ), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 3be04b57a36..509a067d08b 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -47,4 +47,41 @@ describe("config HttpApi", () => { lsp: false, }) }) + + test("serves config with active provider model status", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }, + }) + + const response = await app().request("/config", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }) + }) }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe98..62e1b8fe8d4 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1065,7 +1065,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options?: { [key: string]: unknown } @@ -3012,7 +3012,7 @@ export type ProviderListResponses = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options: { [key: string]: unknown } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0d8ab611790..186128242f7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1060,7 +1060,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" provider?: { npm?: string api?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 270749dc1af..3717f57ec06 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11725,7 +11725,7 @@ }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated"] + "enum": ["alpha", "beta", "deprecated", "active"] }, "provider": { "type": "object", From 6849f968253bef5b7cbf7d8a94e8a11c2fe89b14 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 19:20:31 -0400 Subject: [PATCH 032/201] refactor(provider): share model status schema (#26595) Co-authored-by: Developer --- packages/opencode/src/config/provider.ts | 3 +- .../opencode/src/provider/model-status.ts | 9 +++ packages/opencode/src/provider/models.ts | 3 +- packages/opencode/src/provider/provider.ts | 3 +- packages/opencode/src/v2/model.ts | 3 +- .../test/provider/model-status.test.ts | 61 +++++++++++++++++++ 6 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/provider/model-status.ts create mode 100644 packages/opencode/test/provider/model-status.test.ts diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index e501227ae5e..af9aac69640 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,7 @@ import { Schema } from "effect" import { zod } from "@opencode-ai/core/effect-zod" import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), @@ -49,7 +50,7 @@ export const Model = Schema.Struct({ }), ), experimental: Schema.optional(Schema.Boolean), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])), + status: Schema.optional(ModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/model-status.ts b/packages/opencode/src/provider/model-status.ts new file mode 100644 index 00000000000..468b59ce39d --- /dev/null +++ b/packages/opencode/src/provider/model-status.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" + +export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) +export type CatalogModelStatus = typeof CatalogModelStatus.Type + +export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +export type ModelStatus = typeof ModelStatus.Type + +export * as ProviderModelStatus from "./model-status" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index a2b6ee2a79e..fb240e4cf17 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" +import { CatalogModelStatus } from "./model-status" const Cost = Schema.Struct({ input: Schema.Finite, @@ -71,7 +72,7 @@ export const Model = Schema.Struct({ ), }), ), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])), + status: Schema.optional(CatalogModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 18974ea6460..c27b69b6a20 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -28,6 +28,7 @@ import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" +import { ModelStatus } from "./model-status" const log = Log.create({ service: "provider" }) @@ -897,7 +898,7 @@ export const Model = Schema.Struct({ capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts index a3ee8821074..56357ab4006 100644 --- a/packages/opencode/src/v2/model.ts +++ b/packages/opencode/src/v2/model.ts @@ -1,4 +1,5 @@ import { withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" @@ -114,7 +115,7 @@ export class Info extends Schema.Class("Model.Info")({ released: DateTimeUtcFromMillis, }), cost: Cost.pipe(Schema.Array), - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, limit: Schema.Struct({ context: Schema.Int, input: Schema.Int.pipe(Schema.optional), diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts new file mode 100644 index 00000000000..e6fa645e714 --- /dev/null +++ b/packages/opencode/test/provider/model-status.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigProvider } from "@/config/provider" +import { CatalogModelStatus, ModelStatus } from "@/provider/model-status" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" + +describe("provider model status schemas", () => { + test("keeps catalog status separate from normalized provider status", () => { + expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated") + expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow() + expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active") + }) + + test("accepts active status across public provider schemas", () => { + expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active") + expect( + Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }).status, + ).toBeUndefined() + expect( + Schema.decodeUnknownSync(Provider.Model)({ + id: "test-model", + providerID: "test-provider", + api: { + id: "test-model", + url: "", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + }).status, + ).toBe("active") + }) +}) From b8ca71d309349147c160105d9c171b850b138f2e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 19:51:55 -0400 Subject: [PATCH 033/201] fix(task): subagent inherits parent agent's deny rules (Plan Mode security bypass) (#26597) Co-authored-by: Developer --- .../src/agent/subagent-permissions.ts | 33 ++++ packages/opencode/src/tool/task.ts | 37 ++--- .../agent/plan-mode-subagent-bypass.test.ts | 141 ++++++++++++++++++ 3 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/src/agent/subagent-permissions.ts create mode 100644 packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts new file mode 100644 index 00000000000..1174ec31ade --- /dev/null +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -0,0 +1,33 @@ +import type { Permission } from "../permission" +import type { Agent } from "./agent" + +/** + * Build the `permission` ruleset for a subagent's session when it's spawned + * via the task tool. Combines: + * + * 1. The parent **agent's** deny rules — Plan Mode and other agent-level + * restrictions live on the agent ruleset, not on the session, so a + * subagent that only inherited the parent SESSION's permission would + * silently bypass them. (#26514) + * 2. The parent **session's** deny rules and external_directory rules — + * same forwarding the original code already did. + * 3. Default `todowrite` and `task` denies if the subagent's own ruleset + * doesn't already permit them. + */ +export function deriveSubagentSessionPermission(input: { + parentSessionPermission: Permission.Ruleset + parentAgent: Agent.Info | undefined + subagent: Agent.Info +}): Permission.Ruleset { + const canTask = input.subagent.permission.some((rule) => rule.permission === "task") + const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") + const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? [] + return [ + ...parentAgentDenies, + ...input.parentSessionPermission.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), + ...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), + ] +} diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 22e4e5671c8..c4d5bf7f4ae 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,6 +4,7 @@ import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" +import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Effect, Exit, Schema } from "effect" @@ -58,41 +59,25 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } - const canTask = next.permission.some((rule) => rule.permission === id) - const canTodo = next.permission.some((rule) => rule.permission === "todowrite") - const taskID = params.task_id const session = taskID ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const parent = yield* sessions.get(ctx.sessionID) + const parentAgent = parent.agent + ? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined const nextSession = session ?? (yield* sessions.create({ parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ - ...(parent.permission ?? []).filter( - (rule) => rule.permission === "external_directory" || rule.action === "deny", - ), - ...(canTodo - ? [] - : [ - { - permission: "todowrite" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(canTask - ? [] - : [ - { - permission: id, - pattern: "*" as const, - action: "deny" as const, - }, - ]), + ...deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + parentAgent, + subagent: next, + }), ...(cfg.experimental?.primary_tools?.map((item) => ({ pattern: "*", action: "allow" as const, @@ -144,8 +129,8 @@ export const TaskTool = Tool.define( }, agent: next.name, tools: { - ...(canTodo ? {} : { todowrite: false }), - ...(canTask ? {} : { task: false }), + ...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }), + ...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }), ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), }, parts, diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts new file mode 100644 index 00000000000..5ba6b54834b --- /dev/null +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -0,0 +1,141 @@ +/** + * Reproducer for opencode issue #26514: + * + * In Plan Mode (the `plan` agent), the main agent's edit/write tools are + * blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`). + * However, when the plan agent spawns a subagent via the `task` tool, the + * subagent retains full file modification capabilities — a security bypass. + * + * This test replicates the permission ruleset that would govern a + * `general` subagent when launched from a `plan` parent session, mirroring + * the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime + * subagent agent permissions, evaluated as in `session/prompt.ts`). + * + * The expected (secure) behavior is that the subagent inherits the plan + * mode read-only restriction and `edit`/`write` resolve to `deny`. On + * origin/dev this assertion fails because the parent **agent** permissions + * are not propagated to the subagent — only the parent **session** + * permissions are passed through, and Plan Mode's restrictions live on the + * agent, not the session. + */ +import { test, expect, afterEach } from "bun:test" +import { Effect } from "effect" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { Agent } from "../../src/agent/agent" +import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" +import { Permission } from "../../src/permission" + +afterEach(async () => { + await disposeAllInstances() +}) + +function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { + return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +} + +// `deriveSubagentSessionPermission` is imported from production. The test +// exercises the actual helper that task.ts uses to build the subagent's +// session permission, so any regression in that helper trips this test. + +test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const generalAgent = await load(tmp.path, (svc) => svc.get("general")) + + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + + // Simulate the plan-mode parent session: in real flow the plan + // session's `permission` field is empty (Plan Mode lives on the agent + // ruleset, not the session). So we pass [] through as the parent + // session permission, exactly like the actual code path. + const parentSessionPermission: Permission.Ruleset = [] + + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: generalAgent!, + }) + + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { + // Sibling check: even though `explore` is intrinsically read-only, the + // bug surface is the same. Including this case to document that the fix + // should propagate the parent **agent** permissions, not just deny edit + // when the subagent happens to already deny it. + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const explore = await load(tmp.path, (svc) => svc.get("explore")) + expect(planAgent).toBeDefined() + expect(explore).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) + + // Already deny — sanity check. + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { + // The most damaging case: a user-defined subagent with default + // permissions (allow-by-default, like `general`). The subagent must NOT + // be able to edit when the parent agent is `plan`. + await using tmp = await tmpdir({ + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const my = await load(tmp.path, (svc) => svc.get("my_subagent")) + expect(planAgent).toBeDefined() + expect(my).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: my!, + }) + const effective = Permission.merge(my!.permission, subagentSessionPermission) + + // BUG: on origin/dev edit resolves to "allow" because the plan + // agent's `edit: deny *` rule never reaches the subagent. + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + }, + }) +}) From 7a79f3a5ea8967f8e23b7b7fff5a15b9ac650bf0 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 10 May 2026 00:07:24 +0000 Subject: [PATCH 034/201] sync release versions for v1.14.45 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 17b0caccd1a..22ad0279a7c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.44", + "version": "1.14.45", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.44", + "version": "1.14.45", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 0b18298ba6d..16d1f6b10dc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.44", + "version": "1.14.45", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 8ce88d49481..1ec8d79a66a 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 93655393026..2c7f23d6df1 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.44", + "version": "1.14.45", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 3e1daabd528..a5f01a32ab3 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.44", + "version": "1.14.45", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 127a352de55..c522a5f2831 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index fc7a8f84d19..a68a1d3f7af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.44", + "version": "1.14.45", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 355db03fe4a..1d3f9b961b1 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index ecf6d05191f..7c7cc1ed24e 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.44", + "version": "1.14.45", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8dcd84b37fd..7d84cd37302 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.44" +version = "1.14.45" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 6f871cb8c66..731e8145010 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.44", + "version": "1.14.45", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index f4874d763a4..ff23648540a 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.44", + "version": "1.14.45", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index e983f19e80e..4d2c2fdfbf2 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.44", + "version": "1.14.45", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cd58a663f81..f55b75c0d26 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.44", + "version": "1.14.45", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 10f9b6014d7..5e7deabcdf7 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 10b306ea31e..bd881db4515 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 813df5b04ef..c30e9c3b549 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 7c92c131075..75a85094005 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 6b923e2331e..47f1d91e856 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.44", + "version": "1.14.45", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index b39d872b513..f04148b8814 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.44", + "version": "1.14.45", "publisher": "sst-dev", "repository": { "type": "git", From e30d8173c1a01cb3ad1b37b964f9f8ec60d81f9b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 21:12:34 -0400 Subject: [PATCH 035/201] Fix OpenAPI workspace query drift (#26609) --- .../specs/openapi-translation-cleanup.md | 204 ++++++++++++++++++ .../server/routes/instance/httpapi/event.ts | 2 + .../routes/instance/httpapi/groups/config.ts | 5 +- .../instance/httpapi/groups/experimental.ts | 15 +- .../routes/instance/httpapi/groups/file.ts | 7 +- .../instance/httpapi/groups/instance.ts | 17 +- .../routes/instance/httpapi/groups/mcp.ts | 10 +- .../instance/httpapi/groups/permission.ts | 4 +- .../routes/instance/httpapi/groups/project.ts | 6 +- .../instance/httpapi/groups/provider.ts | 6 +- .../routes/instance/httpapi/groups/pty.ts | 14 +- .../instance/httpapi/groups/question.ts | 5 +- .../routes/instance/httpapi/groups/session.ts | 30 ++- .../routes/instance/httpapi/groups/sync.ts | 6 +- .../routes/instance/httpapi/groups/tui.ts | 15 +- .../instance/httpapi/groups/v2/message.ts | 55 ++--- .../instance/httpapi/groups/v2/session.ts | 88 +++----- .../instance/httpapi/groups/workspace.ts | 9 +- .../instance/httpapi/handlers/v2/message.ts | 1 + .../instance/httpapi/handlers/v2/session.ts | 27 +++ .../httpapi/middleware/workspace-routing.ts | 2 + .../server/routes/instance/httpapi/public.ts | 31 +-- .../test/server/httpapi-exercise/index.ts | 109 ++++++++++ .../server/httpapi-query-schema-drift.test.ts | 94 ++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 182 +++++++++------- 26 files changed, 752 insertions(+), 212 deletions(-) create mode 100644 packages/opencode/specs/openapi-translation-cleanup.md diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md new file mode 100644 index 00000000000..abf8fb2c4cf --- /dev/null +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -0,0 +1,204 @@ +# OpenAPI Translation Cleanup Plan + +## Goal + +Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface. + +The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation. + +## Current Culprit + +`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility. + +The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime. + +## Non-Negotiables + +- Do not break the generated JavaScript SDK without an explicit versioned migration plan. +- Runtime route schemas are the source of truth for accepted params, payloads, and responses. +- `/doc`, generated SDK types, and runtime validation must agree for every endpoint. +- Prefer endpoint or schema annotations over post-generation spec surgery. +- Remove one category of rewrite at a time, with focused compatibility checks. + +## PR Checklist + +Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started. + +Current combined PR scope: + +- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes. +- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs. + +### PR 1: Add OpenAPI/Runtime Query Drift Tests + +- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` Import `OpenApi.fromApi` and `PublicApi`. +- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`. +- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes. +- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema. +- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params. +- `[x]` Keep this part test-only. + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 2: Delete Spec-Only Workspace Query Injection + +- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- `[x]` Delete `InstanceQueryParameters`. +- `[x]` Delete the `isInstanceRoute` constant. +- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation. +- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`. +- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes. +- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only. + +Notes: + +- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params. +- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing. + +Expected code shape: + +```ts +for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`) +``` + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `[x]` `./packages/sdk/js/script/build.ts` from repo root. +- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers + +- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time. +- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`. +- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`. +- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet. + +Concrete first targets: + +- Replace `roots` / `archived` reliance on `QueryBooleanParameters` with explicit route schema helpers. +- Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing. +- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. + +Verification: + +- Focused HTTP tests for changed query fields. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK request param types before deleting each override. +- `bun typecheck` from `packages/opencode`. + +### PR 4: Move Path Parameter Patterns Into ID Schemas + +- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. +- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. +- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Delete one path override only after generated OpenAPI is unchanged for that param. + +Concrete first targets: + +- `sessionID` +- `messageID` +- `partID` +- `permissionID` +- `ptyID` + +Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated path param types and patterns. +- `bun typecheck` from `packages/opencode`. + +### PR 5: Replace Built-In Error Rewrites With Declared API Errors + +- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`. +- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there. +- Update handlers to fail with the declared API errors at the boundary. +- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible. +- Do this group by group, starting with one small route group. + +Concrete first targets: + +- `groups/config.ts` `PATCH /config` bad request. +- `groups/session.ts` endpoints that already translate domain not-found errors. +- `groups/file.ts` if any handler currently relies on built-in error shape. + +Verification: + +- Focused HTTP tests asserting response body shape for changed error paths. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect SDK error union diff. +- `bun typecheck` from `packages/opencode`. + +### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It + +- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`. +- Decide whether SDK should expose auth in generated operation metadata. +- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code. +- If removing it, update SDK generation expectations and docs in the same PR. + +Verification: + +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated client call signatures and error unions. +- Do not merge if auth churn changes normal SDK call ergonomics unintentionally. + +### PR 7: Tackle Component Shape Rewrites One At A Time + +- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`. +- For each rewrite, make a tiny PR that removes or narrows only that rewrite. +- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first. + +Concrete first targets: + +- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially. +- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source. +- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK type-name and optionality diffs. + +## Upstream Middleware Query Support + +Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation. + +Target in `effect-smol`: + +- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation. +- Make runtime request decoding include middleware query schemas. +- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware. + +Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`. + +## Suggested PR Order + +1. Add drift detection tests only. +2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas. +3. Convert query type overrides into route/schema-level helpers where possible. +4. Convert path parameter overrides into schema annotations or upstream fixes. +5. Replace built-in error response rewrites with explicit declared API errors by route group. +6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable. + +## Verification Checklist Per PR + +- Focused HTTP tests for changed routes. +- OpenAPI drift tests. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK diff for public API churn. +- `bun typecheck` from `packages/opencode`. diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index a5c328ac0e3..8113c76f51d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import { HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" +import { WorkspaceRoutingQuery } from "./middleware/workspace-routing" const log = Log.create({ service: "server" }) @@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add( HttpApiGroup.make("event") .add( HttpApiEndpoint.get("subscribe", EventPaths.event, { + query: WorkspaceRoutingQuery, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index fa77785a9bc..a86845beff3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/config" @@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config") HttpApiGroup.make("config") .add( HttpApiEndpoint.get("get", root, { + query: WorkspaceRoutingQuery, success: described(Config.Info, "Get config info"), }).annotateMerge( OpenApi.annotations({ @@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.patch("update", root, { + query: WorkspaceRoutingQuery, payload: Config.Info, success: described(Config.Info, "Successfully updated config"), error: HttpApiError.BadRequest, @@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.get("providers", `${root}/providers`, { + query: WorkspaceRoutingQuery, success: described(Provider.ConfigProvidersResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index d2f3c65ad21..411e7398f89 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -8,7 +8,11 @@ import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ @@ -82,6 +86,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiGroup.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { + query: WorkspaceRoutingQuery, success: described(ConsoleStateResponse, "Active Console provider metadata"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -92,6 +97,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { + query: WorkspaceRoutingQuery, success: described(ConsoleOrgList, "Switchable Console orgs"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -102,6 +108,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { + query: WorkspaceRoutingQuery, payload: ConsoleSwitchPayload, success: described(Schema.Boolean, "Switch success"), error: HttpApiError.BadRequest, @@ -125,6 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { + query: WorkspaceRoutingQuery, success: described(ToolIDs, "Tool IDs"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -136,6 +144,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, success: described(WorktreeList, "List of worktree directories"), }).annotateMerge( OpenApi.annotations({ @@ -145,6 +154,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Schema.optional(Worktree.CreateInput), success: described(Worktree.Info, "Worktree created"), error: HttpApiError.BadRequest, @@ -156,6 +166,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Worktree.RemoveInput, success: described(Schema.Boolean, "Worktree removed"), error: HttpApiError.BadRequest, @@ -167,6 +178,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { + query: WorkspaceRoutingQuery, payload: Worktree.ResetInput, success: described(Schema.Boolean, "Worktree reset"), error: HttpApiError.BadRequest, @@ -189,6 +201,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index fe0b0b617cb..c636e583d7b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -5,7 +5,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ @@ -97,6 +101,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.get("status", FilePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 5c45cd5c155..ea8db35035d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -8,7 +8,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -55,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance") HttpApiGroup.make("instance") .add( HttpApiEndpoint.post("dispose", InstancePaths.dispose, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Instance disposed"), }).annotateMerge( OpenApi.annotations({ @@ -64,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("path", InstancePaths.path, { + query: WorkspaceRoutingQuery, success: PathInfo, }).annotateMerge( OpenApi.annotations({ @@ -74,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcs", InstancePaths.vcs, { + query: WorkspaceRoutingQuery, success: described(Vcs.Info, "VCS info"), }).annotateMerge( OpenApi.annotations({ @@ -84,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Vcs.FileStatus), "VCS status"), }).annotateMerge( OpenApi.annotations({ @@ -103,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + query: WorkspaceRoutingQuery, success: described( Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), "Raw VCS diff", @@ -115,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + query: WorkspaceRoutingQuery, payload: Vcs.ApplyInput, success: described(Vcs.ApplyResult, "VCS patch applied"), error: ApiVcsApplyError, @@ -126,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("command", InstancePaths.command, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( OpenApi.annotations({ @@ -135,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("agent", InstancePaths.agent, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Agent.Info), "List of agents"), }).annotateMerge( OpenApi.annotations({ @@ -144,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("skill", InstancePaths.skill, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Skill.Info), "List of skills"), }).annotateMerge( OpenApi.annotations({ @@ -153,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("lsp", InstancePaths.lsp, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(LSP.Status), "LSP server status"), }).annotateMerge( OpenApi.annotations({ @@ -162,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("formatter", InstancePaths.formatter, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Format.Status), "Formatter status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index b30714c196a..c7ed4a9b95d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" export const AddPayload = Schema.Struct({ @@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiGroup.make("mcp") .add( HttpApiEndpoint.get("status", McpPaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), }).annotateMerge( OpenApi.annotations({ @@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp") }), ), HttpApiEndpoint.post("add", McpPaths.status, { + query: WorkspaceRoutingQuery, payload: AddPayload, success: described(StatusMap, "MCP server added successfully"), error: HttpApiError.BadRequest, @@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthStartResponse, "OAuth flow started"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, payload: AuthCallbackPayload, success: described(MCP.Status, "OAuth authentication completed"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(MCP.Status, "OAuth authentication completed"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthRemoveResponse, "OAuth credentials removed"), error: HttpApiError.NotFound, }).annotateMerge( @@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server connected successfully"), }).annotateMerge( OpenApi.annotations({ @@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server disconnected successfully"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 22c4d6f6d32..5326596d399 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/permission" @@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission") HttpApiGroup.make("permission") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Permission.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: PermissionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 1a2084547d0..f95199eb011 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/project" @@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project") HttpApiGroup.make("project") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Project.Info), "List of projects"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.get("current", `${root}/current`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Current project information"), }).annotateMerge( OpenApi.annotations({ @@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.post("initGit", `${root}/git/init`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Project information after git initialization"), }).annotateMerge( OpenApi.annotations({ @@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project") ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { params: { projectID: ProjectID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 4a9bbffc549..49792898df3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/provider" @@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider") HttpApiGroup.make("provider") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Provider.ListResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ @@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider") }), ), HttpApiEndpoint.get("auth", `${root}/auth`, { + query: WorkspaceRoutingQuery, success: described(ProviderAuth.Methods, "Provider auth methods"), }).annotateMerge( OpenApi.annotations({ @@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.AuthorizeInput, success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), error: HttpApiError.BadRequest, @@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.CallbackInput, success: described(Schema.Boolean, "OAuth callback processed successfully"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index 17f5890f1ec..1391d2a919a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -5,7 +5,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -37,6 +41,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiGroup.make("pty") .add( HttpApiEndpoint.get("shells", PtyPaths.shells, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(ShellItem), "List of shells"), }).annotateMerge( OpenApi.annotations({ @@ -46,6 +51,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.get("list", PtyPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Pty.Info), "List of sessions"), }).annotateMerge( OpenApi.annotations({ @@ -55,6 +61,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.post("create", PtyPaths.create, { + query: WorkspaceRoutingQuery, payload: Pty.CreateInput, success: described(Pty.Info, "Created session"), error: HttpApiError.BadRequest, @@ -67,6 +74,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.get("get", PtyPaths.get, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Pty.Info, "Session info"), error: ApiNotFoundError, }).annotateMerge( @@ -78,6 +86,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.put("update", PtyPaths.update, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -90,6 +99,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.delete("remove", PtyPaths.remove, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session removed"), error: ApiNotFoundError, }).annotateMerge( @@ -101,6 +111,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), error: [HttpApiError.Forbidden, ApiNotFoundError], }).annotateMerge( @@ -129,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( .add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Connected session"), error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index de2d4fca8ec..35cd3314b54 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/question" @@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question") HttpApiGroup.make("question") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Question.Request), "List of pending questions"), }).annotateMerge( OpenApi.annotations({ @@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Question answered successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Question rejected successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index f1dc3697b94..4a11db09a97 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -14,7 +14,11 @@ import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -116,6 +120,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.get("status", SessionPaths.status, { + query: WorkspaceRoutingQuery, success: described(StatusMap, "Get session status"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -127,6 +132,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Get session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -138,6 +144,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("children", SessionPaths.children, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Session.Info), "List of children"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -149,6 +156,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("todo", SessionPaths.todo, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Todo.Info), "Todo list"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -183,6 +191,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("message", SessionPaths.message, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(MessageV2.WithParts, "Message"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -193,6 +202,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.post("create", SessionPaths.create, { + query: WorkspaceRoutingQuery, payload: [HttpApiSchema.NoContent, Session.CreateInput], success: described(Session.Info, "Successfully created session"), error: HttpApiError.BadRequest, @@ -205,6 +215,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("remove", SessionPaths.remove, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -216,6 +227,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("update", SessionPaths.update, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Session.Info, "Successfully updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -228,6 +240,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ForkPayload, success: described(Session.Info, "200"), error: ApiNotFoundError, @@ -240,6 +253,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("abort", SessionPaths.abort, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Aborted session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -251,6 +265,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("init", SessionPaths.init, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: InitPayload, success: described(Schema.Boolean, "200"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -264,6 +279,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully shared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -275,6 +291,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully unshared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -286,6 +303,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("summarize", SessionPaths.summarize, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: SummarizePayload, success: described(Schema.Boolean, "Summarized session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -298,6 +316,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("prompt", SessionPaths.prompt, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -310,6 +329,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(HttpApiSchema.NoContent, "Prompt accepted"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -323,6 +343,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("command", SessionPaths.command, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -335,6 +356,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("shell", SessionPaths.shell, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ShellPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -347,6 +369,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("revert", SessionPaths.revert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: RevertPayload, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -360,6 +383,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -371,6 +395,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { params: { sessionID: SessionID, permissionID: PermissionID }, + query: WorkspaceRoutingQuery, payload: PermissionResponsePayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -384,6 +409,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -396,6 +422,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -406,6 +433,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, payload: MessageV2.Part, success: described(MessageV2.Part, "Successfully updated part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 1603c29c4cc..38a93240eb7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/sync" @@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync") HttpApiGroup.make("sync") .add( HttpApiEndpoint.post("start", SyncPaths.start, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Workspace sync started"), }).annotateMerge( OpenApi.annotations({ @@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("replay", SyncPaths.replay, { + query: WorkspaceRoutingQuery, payload: ReplayPayload, success: described(ReplayResponse, "Replayed sync events"), error: HttpApiError.BadRequest, @@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("steal", SyncPaths.steal, { + query: WorkspaceRoutingQuery, payload: SessionPayload, success: described(SessionPayload, "Session stolen into workspace"), error: HttpApiError.BadRequest, @@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("history", SyncPaths.history, { + query: WorkspaceRoutingQuery, payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 3676fc1964c..3cf3de5b8eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -54,6 +54,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiGroup.make("tui") .add( HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + query: WorkspaceRoutingQuery, payload: TuiEvent.PromptAppend.properties, success: described(Schema.Boolean, "Prompt processed successfully"), error: HttpApiError.BadRequest, @@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Help dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Theme dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openModels", TuiPaths.openModels, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Model dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt submitted successfully"), }).annotateMerge( OpenApi.annotations({ @@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt cleared successfully"), }).annotateMerge( OpenApi.annotations({ @@ -119,6 +126,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(Schema.Boolean, "Command executed successfully"), error: HttpApiError.BadRequest, @@ -130,6 +138,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + query: WorkspaceRoutingQuery, payload: TuiEvent.ToastShow.properties, success: described(Schema.Boolean, "Toast notification shown successfully"), }).annotateMerge( @@ -140,6 +149,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("publish", TuiPaths.publish, { + query: WorkspaceRoutingQuery, payload: TuiPublishPayload, success: described(Schema.Boolean, "Event published successfully"), error: HttpApiError.BadRequest, @@ -151,6 +161,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + query: WorkspaceRoutingQuery, payload: TuiEvent.SessionSelect.properties, success: described(Schema.Boolean, "Session selected successfully"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { + query: WorkspaceRoutingQuery, success: described(TuiRequestPayload, "Next TUI request"), }).annotateMerge( OpenApi.annotations({ @@ -171,6 +183,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + query: WorkspaceRoutingQuery, payload: Schema.Unknown, success: described(Schema.Boolean, "Response submitted successfully"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 3b0b2fa5b10..060c6c8a83e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" + +export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "V2SessionMessagesQuery" }) export const MessageGroup = HttpApiGroup.make("v2.message") .add( HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { params: { sessionID: SessionID }, - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Message order for the first page. Use desc for newest first or asc for oldest first.", - }), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionMessagesQuery" }), + query: MessagesQuery, success: Schema.Struct({ items: Schema.Array(SessionMessage.Message), cursor: Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 17ddcaeda3b..8b49382a77c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,4 +1,3 @@ -import { WorkspaceID } from "@/control-plane/schema" import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" import { Prompt } from "@/v2/session-prompt" @@ -6,62 +5,41 @@ import { SessionV2 } from "@/v2/session" import { Schema, SchemaGetter } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" + +const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) + +export const SessionsQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.", + }), + ), +}).annotate({ identifier: "V2SessionsQuery" }) export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - directory: Schema.String.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - workspace: WorkspaceID.pipe(Schema.optional), - roots: Schema.Literals(["true", "false"]) - .pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), - ) - .pipe(Schema.optional), - start: Schema.NumberFromString.pipe(Schema.optional), - search: Schema.String.pipe(Schema.optional), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - directory: Schema.optional(Schema.Never), - path: Schema.optional(Schema.Never), - workspace: Schema.optional(Schema.Never), - roots: Schema.optional(Schema.Never), - start: Schema.optional(Schema.Never), - search: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionsQuery" }), + query: SessionsQuery, success: Schema.Struct({ items: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ @@ -82,6 +60,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: Schema.Struct({ prompt: Prompt, delivery: SessionV2.Delivery.pipe(Schema.optional), @@ -98,6 +77,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -110,6 +90,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -122,6 +103,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: Schema.Array(SessionMessage.Message), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 1a9732800db..1c40ae3cb8d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" @@ -40,6 +40,7 @@ export const WorkspaceApi = HttpApi.make("workspace") HttpApiGroup.make("workspace") .add( HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ @@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.Info), "Workspaces"), }).annotateMerge( OpenApi.annotations({ @@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("create", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, payload: CreatePayload, success: described(Workspace.Info, "Workspace created"), error: HttpApiError.BadRequest, @@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, { + query: WorkspaceRoutingQuery, success: described(HttpApiSchema.NoContent, "Workspace list synced"), }).annotateMerge( OpenApi.annotations({ @@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("status", WorkspacePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"), }).annotateMerge( OpenApi.annotations({ @@ -88,6 +93,7 @@ export const WorkspaceApi = HttpApi.make("workspace") ), HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { params: { id: Workspace.Info.fields.id }, + query: WorkspaceRoutingQuery, success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + query: WorkspaceRoutingQuery, payload: WarpPayload, success: described(HttpApiSchema.NoContent, "Session warped"), error: [ApiWorkspaceWarpError, ApiVcsApplyError], diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 3485d80fd63..92e37142b40 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message return handlers.handle( "messages", Effect.fn(function* (ctx) { + if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 558e34dd184..275fa2956c4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type const decodeCursor = Schema.decodeUnknownSync(SessionCursor) +function hasCursorFilter(query: { + readonly order?: unknown + readonly path?: unknown + readonly roots?: unknown + readonly start?: unknown + readonly search?: unknown +}) { + return ( + query.order !== undefined || + query.path !== undefined || + query.roots !== undefined || + query.start !== undefined || + query.search !== undefined + ) +} + +function hasCursorRoutingMismatch( + query: { readonly directory?: string; readonly workspace?: string }, + decoded: SessionCursor | undefined, +) { + if (!decoded) return false + if (query.directory !== undefined && query.directory !== decoded.directory) return true + return query.workspace !== undefined && query.workspace !== decoded.workspaceID +} + const sessionCursor = { encode( session: SessionV2.Info, @@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "sessions", Effect.fn(function* (ctx) { + if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), }) + if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({}) const order = decoded?.order ?? ctx.query.order ?? "desc" const filters = decoded ?? { directory: ctx.query.directory, diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index fd5d534bbc3..1d665fd5c95 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -24,6 +24,8 @@ export const WorkspaceRoutingQueryFields = { workspace: Schema.optional(Schema.String), } +export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields) + type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index b2ac719a2ae..876acf5be54 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -51,30 +51,14 @@ type OpenApiResponse = { content?: Record } -// Instance routes use middleware for directory/workspace resolution, but HttpApi -// doesn't surface middleware query params in the spec. Inject them explicitly. -const InstanceQueryParameters = [ - { - name: "directory", - in: "query", - required: false, - schema: { type: "string" }, - }, - { - name: "workspace", - in: "query", - required: false, - schema: { type: "string" }, - }, -] satisfies OpenApiParameter[] - // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) +const QueryNumberParameters = new Set(["start", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /experimental/session cursor": { type: "number" }, "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, } satisfies Record @@ -122,7 +106,6 @@ function matchLegacyOpenApi(input: Record) { delete spec.components?.securitySchemes for (const [path, item] of Object.entries(spec.paths ?? {})) { - const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue @@ -183,14 +166,8 @@ function matchLegacyOpenApi(input: Record) { }, } } - if (!isInstanceRoute) continue - operation.parameters = [ - ...InstanceQueryParameters, - ...(operation.parameters ?? []).filter( - (param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"), - ), - ] - for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`) + const route = `${method.toUpperCase()} ${path}` + for (const param of operation.parameters ?? []) normalizeParameter(param, route) } } return input diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 4560973abea..72bf866cb80 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -38,6 +38,10 @@ import { type Scenario } from "./types" void (await import("@opencode-ai/core/util/log")).init({ print: false }) +function cursor(input: Record) { + return Buffer.from(JSON.stringify(input)).toString("base64url") +} + const scenarios: Scenario[] = [ http.protected .get("/global/health", "global.health") @@ -598,6 +602,64 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session", "v2.session.list.filters") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + order: "asc", + path: ".", + roots: "false", + start: "0", + search: "missing", + directory: ctx.directory ?? "", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ + id: "ses_httpapi_missing", + time: 0, + order: "desc", + direction: "next", + directory: ctx.directory, + }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor.invalid") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }), + search: "not-allowed-with-cursor", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ @@ -620,6 +682,53 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.params") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + order: "asc", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + order: "asc", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .at((ctx) => ({ diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 68daeca1e96..ad07dbbb7b1 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -1,14 +1,53 @@ import { afterEach, describe, expect } from "bun:test" import { Effect } from "effect" +import { OpenApi } from "effect/unstable/httpapi" import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" import { SessionID } from "../../src/session/schema" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { + FilePaths, + FileQuery, + FindFileQuery, + FindTextQuery, +} from "../../src/server/routes/instance/httpapi/groups/file" +import { + ExperimentalPaths, + SessionListQuery as ExperimentalSessionListQuery, + ToolListQuery, +} from "../../src/server/routes/instance/httpapi/groups/experimental" +import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { + ListQuery as SessionListQuery, + MessagesQuery, + SessionPaths, +} from "../../src/server/routes/instance/httpapi/groups/session" +import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" +import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +type Method = "get" | "post" | "put" | "delete" | "patch" +type QuerySchema = { readonly fields: Record } +type OpenApiParameter = { readonly name: string; readonly in: string } +type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } + +const openApiDriftRoutes = [ + { method: "get", path: SessionPaths.list, query: SessionListQuery }, + { method: "get", path: SessionPaths.messages, query: MessagesQuery }, + { method: "get", path: FilePaths.findFile, query: FindFileQuery }, + { method: "get", path: FilePaths.findText, query: FindTextQuery }, + { method: "get", path: FilePaths.list, query: FileQuery }, + { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, + { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, + { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, + { method: "get", path: "/api/session", query: V2SessionsQuery }, + { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, +] satisfies Array<{ method: Method; path: string; query: QuerySchema }> + function app() { return Server.Default().app } @@ -27,6 +66,29 @@ function withTmp( ).pipe(Effect.flatMap(fn)) } +function openApiPath(path: string) { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") +} + +function queryParameters(operation: OpenApiOperation | undefined) { + return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) +} + +function assertAdvertisedQueryParamsAreRuntimeFields(input: { + readonly method: Method + readonly operation: OpenApiOperation | undefined + readonly path: string + readonly query: QuerySchema +}) { + const runtimeFields = new Set(Object.keys(input.query.fields)) + const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) + + expect( + advertisedOnly, + `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`, + ).toEqual([]) +} + afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() @@ -44,6 +106,38 @@ describe("httpapi query schema drift", () => { expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) } + it.effect( + "OpenAPI workspace query params are declared by runtime query schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const route of openApiDriftRoutes) { + assertAdvertisedQueryParamsAreRuntimeFields({ + ...route, + operation: spec.paths[openApiPath(route.path)]?.[route.method], + }) + } + }), + ) + + it.effect( + "drift assertion catches spec-only workspace query params", + Effect.sync(() => { + expect(() => + assertAdvertisedQueryParamsAreRuntimeFields({ + method: "get", + operation: { + parameters: [ + { name: "directory", in: "query" }, + { name: "workspace", in: "query" }, + ], + }, + path: "/fixture", + query: { fields: {} }, + }), + ).toThrow("advertises query params not accepted by runtime schema") + }), + ) + it.live( "session list accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f25596011eb..bf3201a5c08 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4163,6 +4163,13 @@ export class Session3 extends HeyApiClient { parameters?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + cursor?: string }, options?: Options, ) { @@ -4173,6 +4180,13 @@ export class Session3 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "path" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "search" }, + { in: "query", key: "cursor" }, ], }, ], @@ -4331,6 +4345,9 @@ export class Session3 extends HeyApiClient { sessionID: string directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + cursor?: string }, options?: Options, ) { @@ -4342,6 +4359,9 @@ export class Session3 extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 186128242f7..398a902841f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,6 +5,12 @@ export type ClientOptions = { } export type Event = + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -24,10 +30,6 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -75,8 +77,6 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed export type OAuth = { type: "oauth" @@ -103,6 +103,61 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type PermissionRequest = { id: string sessionID: string @@ -280,61 +335,6 @@ export type SessionStatus = type: "busy" } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type Project = { id: string worktree: string @@ -778,6 +778,12 @@ export type GlobalEvent = { project?: string workspace?: string payload: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -797,10 +803,6 @@ export type GlobalEvent = { | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -848,8 +850,6 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2318,6 +2318,22 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3032,22 +3048,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type SessionInfo = { id: string parentID?: string @@ -6235,6 +6235,16 @@ export type V2SessionListData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters. + */ + cursor?: string } url: "/api/session" } @@ -6352,6 +6362,12 @@ export type V2SessionMessagesData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. + */ + cursor?: string } url: "/api/session/{sessionID}/message" } From 6d130e5deb5fbcb25c103e1557f4f2fe31b4774f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 01:13:41 +0000 Subject: [PATCH 036/201] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 166 +- packages/sdk/openapi.json | 2180 ++++++++++++----------- 2 files changed, 1219 insertions(+), 1127 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 398a902841f..4779f7cebd6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,12 +5,6 @@ export type ClientOptions = { } export type Event = - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -30,6 +24,10 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -77,6 +75,8 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed export type OAuth = { type: "oauth" @@ -103,61 +103,6 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type PermissionRequest = { id: string sessionID: string @@ -335,6 +280,61 @@ export type SessionStatus = type: "busy" } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type Project = { id: string worktree: string @@ -778,12 +778,6 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -803,6 +797,10 @@ export type GlobalEvent = { | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -850,6 +848,8 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2318,22 +2318,6 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3048,6 +3032,22 @@ export type EventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type SessionInfo = { id: string parentID?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3717f57ec06..c6839668817 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -115,18 +115,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -446,18 +446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -490,18 +490,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -532,18 +532,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -595,18 +595,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -656,18 +656,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -710,18 +710,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -797,18 +797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -861,18 +861,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "provider", @@ -931,18 +931,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -985,18 +985,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1031,18 +1031,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1092,18 +1092,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1156,18 +1156,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1220,18 +1220,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "roots", @@ -1332,18 +1332,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1380,18 +1380,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "pattern", @@ -1496,18 +1496,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1580,18 +1580,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1636,18 +1636,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1692,18 +1692,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1744,18 +1744,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1792,18 +1792,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1837,18 +1837,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1881,18 +1881,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1925,18 +1925,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1973,18 +1973,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "mode", @@ -2030,18 +2030,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2074,18 +2074,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2152,18 +2152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2200,18 +2200,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2248,18 +2248,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2312,18 +2312,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2360,18 +2360,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2408,18 +2408,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2454,18 +2454,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2535,22 +2535,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.start", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2558,6 +2542,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2616,22 +2616,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2639,6 +2623,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2687,22 +2687,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2710,6 +2694,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2775,22 +2775,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.authenticate", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2798,6 +2782,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2847,22 +2847,6 @@ "tags": ["mcp"], "operationId": "mcp.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2870,6 +2854,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2899,22 +2899,6 @@ "tags": ["mcp"], "operationId": "mcp.disconnect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2922,6 +2906,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2954,18 +2954,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3002,18 +3002,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3046,18 +3046,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3087,22 +3087,6 @@ "tags": ["project"], "operationId": "project.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "projectID", "in": "path", @@ -3110,6 +3094,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3202,18 +3202,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3263,18 +3263,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3309,18 +3309,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3393,22 +3393,6 @@ "tags": ["pty"], "operationId": "pty.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3417,6 +3401,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3454,22 +3454,6 @@ "tags": ["pty"], "operationId": "pty.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3478,6 +3462,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3545,22 +3545,6 @@ "tags": ["pty"], "operationId": "pty.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3569,6 +3553,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3609,22 +3609,6 @@ "tags": ["pty"], "operationId": "pty.connectToken", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3633,6 +3617,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3697,18 +3697,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3742,22 +3742,6 @@ "tags": ["question"], "operationId": "question.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3766,6 +3750,22 @@ "pattern": "^que.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3836,22 +3836,6 @@ "tags": ["question"], "operationId": "question.reject", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3860,6 +3844,22 @@ "pattern": "^que.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3913,18 +3913,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3958,22 +3958,6 @@ "tags": ["permission"], "operationId": "permission.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3982,6 +3966,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4055,18 +4055,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4122,18 +4122,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4170,22 +4170,6 @@ "tags": ["provider"], "operationId": "provider.oauth.authorize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4193,6 +4177,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4255,22 +4255,6 @@ "tags": ["provider"], "operationId": "provider.oauth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4278,6 +4262,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4341,18 +4341,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -4444,18 +4444,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4541,18 +4541,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4596,22 +4596,6 @@ "tags": ["session"], "operationId": "session.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4620,6 +4604,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4667,22 +4667,6 @@ "tags": ["session"], "operationId": "session.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4691,6 +4675,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4739,22 +4739,6 @@ "tags": ["session"], "operationId": "session.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4763,6 +4747,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4839,22 +4839,6 @@ "tags": ["session"], "operationId": "session.children", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4863,6 +4847,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4916,22 +4916,6 @@ "tags": ["session"], "operationId": "session.todo", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4940,6 +4924,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4993,22 +4993,6 @@ "tags": ["session"], "operationId": "session.diff", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5018,6 +5002,22 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "messageID", "in": "query", @@ -5059,22 +5059,6 @@ "tags": ["session"], "operationId": "session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5084,6 +5068,22 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "limit", "in": "query", @@ -5165,22 +5165,6 @@ "tags": ["session"], "operationId": "session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5189,6 +5173,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5319,22 +5319,6 @@ "tags": ["session"], "operationId": "session.message", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5352,6 +5336,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5413,22 +5413,6 @@ "tags": ["session"], "operationId": "session.deleteMessage", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5446,6 +5430,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5496,22 +5496,6 @@ "tags": ["session"], "operationId": "session.fork", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5520,6 +5504,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5574,22 +5574,6 @@ "tags": ["session"], "operationId": "session.abort", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5598,6 +5582,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5648,22 +5648,6 @@ "tags": ["session"], "operationId": "session.init", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5672,6 +5656,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5744,22 +5744,6 @@ "tags": ["session"], "operationId": "session.share", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5768,6 +5752,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5815,22 +5815,6 @@ "tags": ["session"], "operationId": "session.unshare", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5839,6 +5823,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5888,22 +5888,6 @@ "tags": ["session"], "operationId": "session.summarize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5912,6 +5896,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5984,22 +5984,6 @@ "tags": ["session"], "operationId": "session.prompt_async", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6008,6 +5992,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6119,22 +6119,6 @@ "tags": ["session"], "operationId": "session.command", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6143,6 +6127,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6264,22 +6264,6 @@ "tags": ["session"], "operationId": "session.shell", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6288,6 +6272,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6386,22 +6386,6 @@ "tags": ["session"], "operationId": "session.revert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6410,6 +6394,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6478,22 +6478,6 @@ "tags": ["session"], "operationId": "session.unrevert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6502,6 +6486,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6551,22 +6551,6 @@ "tags": ["session"], "operationId": "permission.respond", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6584,6 +6568,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6652,22 +6652,6 @@ "tags": ["session"], "operationId": "part.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6694,6 +6678,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6741,22 +6741,6 @@ "tags": ["session"], "operationId": "part.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6783,6 +6767,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6843,18 +6843,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6888,18 +6888,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6993,18 +6993,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7071,18 +7071,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7162,18 +7162,84 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "roots", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters." + }, + "required": false } ], "responses": { @@ -7213,22 +7279,6 @@ "tags": ["v2"], "operationId": "v2.session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7237,6 +7287,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7285,22 +7351,6 @@ "tags": ["v2"], "operationId": "v2.session.compact", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7309,6 +7359,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7331,22 +7397,6 @@ "tags": ["v2"], "operationId": "v2.session.wait", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7355,6 +7405,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7377,22 +7443,6 @@ "tags": ["v2"], "operationId": "v2.session.context", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7401,6 +7451,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7433,22 +7499,6 @@ "tags": ["v2 messages"], "operationId": "v2.session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7457,6 +7507,48 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + "required": false } ], "responses": { @@ -7499,18 +7591,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7570,18 +7662,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7615,18 +7707,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7660,18 +7752,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7705,18 +7797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7750,18 +7842,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7795,18 +7887,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7840,18 +7932,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7911,18 +8003,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7983,18 +8075,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8060,18 +8152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8142,18 +8234,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8195,18 +8287,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8247,18 +8339,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8308,18 +8400,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8354,18 +8446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8445,18 +8537,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8482,18 +8574,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8538,22 +8630,6 @@ "tags": ["workspace"], "operationId": "experimental.workspace.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "id", "in": "path", @@ -8562,6 +8638,22 @@ "pattern": "^wrk.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -8604,18 +8696,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8684,22 +8776,6 @@ "tags": ["pty"], "operationId": "pty.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -8708,6 +8784,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { From 16866e1180dfeb39505456ec6d69c35e1ab16951 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 21:41:15 -0400 Subject: [PATCH 037/201] Share HTTP API boolean query schema (#26615) --- .../specs/openapi-translation-cleanup.md | 2 +- .../instance/httpapi/groups/experimental.ts | 9 ++------- .../routes/instance/httpapi/groups/query.ts | 8 ++++++++ .../routes/instance/httpapi/groups/session.ts | 9 ++------- .../instance/httpapi/groups/v2/session.ts | 10 ++-------- .../test/server/httpapi-exercise/index.ts | 5 ++++- .../server/httpapi-query-schema-drift.test.ts | 20 ++++++++++++++++++- 7 files changed, 38 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/query.ts diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index abf8fb2c4cf..b61c07feb1d 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -84,7 +84,7 @@ Verification: Concrete first targets: -- Replace `roots` / `archived` reliance on `QueryBooleanParameters` with explicit route schema helpers. +- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. - Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing. - Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 411e7398f89..99a8a21a9e1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -4,7 +4,7 @@ import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { Worktree } from "@/worktree" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { Schema, SchemaGetter } from "effect" +import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" @@ -14,6 +14,7 @@ import { WorkspaceRoutingQueryFields, } from "../middleware/workspace-routing" import { described } from "./metadata" +import { QueryBoolean } from "./query" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), @@ -52,12 +53,6 @@ export const ToolListQuery = Schema.Struct({ model: ModelID, }) -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) const WorktreeList = Schema.Array(Schema.String) export const SessionListQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts new file mode 100644 index 00000000000..d5b10d18003 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -0,0 +1,8 @@ +import { Schema, SchemaGetter } from "effect" + +export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 4a11db09a97..ea68e76cafd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,7 +10,7 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" -import { Schema, SchemaGetter, Struct } from "effect" +import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" @@ -21,14 +21,9 @@ import { } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" +import { QueryBoolean } from "./query" const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) export const ListQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, scope: Schema.optional(Schema.Literals(["project"])), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 8b49382a77c..231f1915bb3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -2,17 +2,11 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" import { Prompt } from "@/v2/session-prompt" import { SessionV2 } from "@/v2/session" -import { Schema, SchemaGetter } from "effect" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" - -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) +import { QueryBoolean } from "../query" export const SessionsQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 72bf866cb80..0d6bec2dfed 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -524,7 +524,10 @@ const scenarios: Scenario[] = [ yield* ctx.worktreeRemove(ctx.state.directory) }), ), - http.protected.get("/experimental/session", "experimental.session.list").json(200, array), + http.protected + .get("/experimental/session", "experimental.session.list") + .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) + .json(200, array), http.protected.get("/experimental/resource", "experimental.resource.list").json(), http.protected .post("/sync/history", "sync.history.list") diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index ad07dbbb7b1..1791a61f565 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { OpenApi } from "effect/unstable/httpapi" import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" @@ -24,6 +24,7 @@ import { } from "../../src/server/routes/instance/httpapi/groups/session" import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" +import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" @@ -106,6 +107,23 @@ describe("httpapi query schema drift", () => { expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) } + it.effect( + "boolean query schema accepts only true and false strings", + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(QueryBoolean) + const encode = Schema.encodeUnknownSync(QueryBoolean) + + expect(decode("true")).toBe(true) + expect(decode("false")).toBe(false) + expect(encode(true)).toBe("true") + expect(encode(false)).toBe("false") + + for (const input of ["1", "yes", "True", "", true, false]) { + expect(() => decode(input)).toThrow() + } + }), + ) + it.effect( "OpenAPI workspace query params are declared by runtime query schemas", Effect.sync(() => { From 6e78f36a0f8e3fc80da216780fb74cae53a92820 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:02:51 -0400 Subject: [PATCH 038/201] Narrow HTTP API numeric query overrides (#26618) --- .../specs/openapi-translation-cleanup.md | 2 +- .../server/routes/instance/httpapi/public.ts | 32 ++++++++-------- .../server/httpapi-query-schema-drift.test.ts | 38 ++++++++++++++++++- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index b61c07feb1d..55e4c7268d4 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -85,7 +85,7 @@ Verification: Concrete first targets: - `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. -- Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing. +- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter. - Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. Verification: diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 876acf5be54..156ebf6834a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -54,28 +54,34 @@ type OpenApiResponse = { // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryNumberParameters = new Set(["start", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) -const QueryParameterSchemas = { +const QueryParameterSchemas: Record = { + "GET /experimental/session start": { type: "number" }, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, "GET /experimental/session cursor": { type: "number" }, + "GET /experimental/session limit": { type: "number" }, + "GET /session start": { type: "number" }, + "GET /session limit": { type: "number" }, "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, -} satisfies Record + "GET /api/session limit": { type: "number" }, + "GET /api/session start": { type: "number" }, + "GET /api/session/{sessionID}/message limit": { type: "number" }, +} -const PathParameterSchemas = { +const PathParameterSchemas: Record = { sessionID: { type: "string", pattern: "^ses.*" }, messageID: { type: "string", pattern: "^msg.*" }, partID: { type: "string", pattern: "^prt.*" }, permissionID: { type: "string", pattern: "^per.*" }, ptyID: { type: "string", pattern: "^pty.*" }, -} satisfies Record +} -const LegacyComponentDescriptions = { +const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", LayoutConfig: "@deprecated Always uses stretch layout.", -} satisfies Record +} function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec @@ -269,7 +275,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) { function normalizeComponentDescriptions(spec: OpenApiSpec) { for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { - const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] + const description = LegacyComponentDescriptions[name] if (description) { schema.description = description continue @@ -415,7 +421,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { } } // Simplest fix: generate the raw spec (without transform) to get correct schemas - const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi) const rawSchemas = raw.components?.schemas if (!rawSchemas) return for (const name of selfRefs) { @@ -484,15 +490,11 @@ function normalizeParameter(param: OpenApiParameter, route: string) { return } if (param.in === "query") { - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + const override = QueryParameterSchemas[`${route} ${param.name}`] if (override) { param.schema = override return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } if (QueryBooleanParameters.has(param.name)) { param.schema = { anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], @@ -504,7 +506,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } function pathParameterSchema(route: string, name: string) { - if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name in PathParameterSchemas) return PathParameterSchemas[name] if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 1791a61f565..3aedd6b814e 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -33,7 +33,8 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES type Method = "get" | "post" | "put" | "delete" | "patch" type QuerySchema = { readonly fields: Record } -type OpenApiParameter = { readonly name: string; readonly in: string } +type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string } +type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } const openApiDriftRoutes = [ @@ -49,6 +50,24 @@ const openApiDriftRoutes = [ { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, ] satisfies Array<{ method: Method; path: string; query: QuerySchema }> +const numericSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } }, + { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } }, + { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } }, + { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } }, + { + method: "get", + path: SessionPaths.messages, + name: "limit", + schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + }, + { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } }, + { method: "get", path: "/api/session", name: "start", schema: { type: "number" } }, + { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, +] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> + function app() { return Server.Default().app } @@ -75,6 +94,10 @@ function queryParameters(operation: OpenApiOperation | undefined) { return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) } +function queryParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) +} + function assertAdvertisedQueryParamsAreRuntimeFields(input: { readonly method: Method readonly operation: OpenApiOperation | undefined @@ -137,6 +160,19 @@ describe("httpapi query schema drift", () => { }), ) + it.effect( + "OpenAPI numeric query params preserve generated SDK call shapes", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of numericSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(expected.schema) + } + }), + ) + it.effect( "drift assertion catches spec-only workspace query params", Effect.sync(() => { From 79d6b10d7ceeaf89ef3019466f1ac72085aa19db Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:03:59 -0400 Subject: [PATCH 039/201] fix(mcp): tolerate output schema ref failures (#26614) --- packages/opencode/src/mcp/index.ts | 48 ++++++++++++-- packages/opencode/test/mcp/lifecycle.test.ts | 68 +++++++++++++++++++- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 20e8c912e11..ed74c648ad5 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -36,6 +37,15 @@ import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 +const TolerantToolSchema = ToolSchema.extend({ + outputSchema: z.unknown().optional(), +}) + +const TolerantListToolsResultSchema = z.looseObject({ + tools: z.array(TolerantToolSchema), + nextCursor: z.string().optional(), +}) + export const Resource = Schema.Struct({ name: Schema.String, uri: Schema.String, @@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) { log.warn("invalid remote mcp url", { key }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + +function listTools(key: string, client: MCPClient, timeout: number) { + return Effect.tryPromise({ + try: () => client.listTools(undefined, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => result.tools), + Effect.catch((error) => { + if (!isOutputSchemaValidationError(error)) return Effect.fail(error) + + log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) + return Effect.tryPromise({ + try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => + result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ) + }), + ) +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number } function defs(key: string, client: MCPClient, timeout?: number) { - return Effect.tryPromise({ - try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((result) => result.tools), + return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe( Effect.catch((err) => { log.error("failed to get tools from client", { key, error: err }) return Effect.succeed(undefined) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 10547c9f082..5afc85e3b5d 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -7,8 +7,9 @@ import type { MCP as MCPNS } from "../../src/mcp/index" // Per-client state for controlling mock behavior interface MockClientState { - tools: Array<{ name: string; description?: string; inputSchema: object }> + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number + requestCalls: number listToolsShouldFail: boolean listToolsError: string listPromptsShouldFail: boolean @@ -36,6 +37,7 @@ function getOrCreateClientState(name?: string): MockClientState { state = { tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], listToolsCalls: 0, + requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", listPromptsShouldFail: false, @@ -139,6 +141,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { tools: this._state?.tools ?? [] } } + async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) { + if (this._state) this._state.requestCalls++ + if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] }) + throw new Error(`unsupported request: ${request.method}`) + } + async listPrompts() { if (this._state?.listPromptsShouldFail) { throw new Error("listPrompts failed") @@ -205,6 +213,11 @@ function withInstance( } } +function statusName(status: Record | MCPNS.Status, server: string) { + if ("status" in status) return status.status + return status[server]?.status +} + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== @@ -433,6 +446,59 @@ test( ), ) +test( + "falls back when MCP output schema refs fail SDK tool discovery", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "stitch-like-server" + const serverState = getOrCreateClientState("stitch-like-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" + serverState.tools = [ + { + name: "render_screen", + description: "renders a screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + }, + ] + + const addResult = yield* mcp.add("stitch-like-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") + + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) + }), + ), +) + +test( + "does not fall back for non-schema MCP tool discovery errors", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "broken-server" + const serverState = getOrCreateClientState("broken-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "transport closed" + + const addResult = yield* mcp.add("broken-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "broken-server")).toBe("failed") + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(0) + }), + ), +) + // ======================================================================== // Test: disabled server via config // ======================================================================== From 10ea59066ff0cf4a416cff36d898d50c9d50642b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:05:37 -0400 Subject: [PATCH 040/201] feat(skill): built-in opencode-meta skill (#26617) --- packages/core/src/flag/flag.ts | 11 + packages/opencode/src/markdown.d.ts | 4 + packages/opencode/src/skill/index.ts | 20 + .../src/skill/prompt/customize-opencode.md | 353 ++++++++++++++++++ packages/opencode/test/preload.ts | 5 + 5 files changed, 393 insertions(+) create mode 100644 packages/opencode/src/markdown.d.ts create mode 100644 packages/opencode/src/skill/prompt/customize-opencode.md diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 175c723c5fd..3fe76557599 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,13 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels where new experiments default to ON (unstable / internal users). +// Stable channels (`prod`, `latest`) stay opt-in. +const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) +function unstableDefault(key: string) { + return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel)) +} + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -48,6 +56,9 @@ export const Flag = { OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + // Default-on for dev/beta/local; opt-in for stable. Set + // OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on. + OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], diff --git a/packages/opencode/src/markdown.d.ts b/packages/opencode/src/markdown.d.ts new file mode 100644 index 00000000000..eb3e3b92d66 --- /dev/null +++ b/packages/opencode/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 3000406ebc5..01bffdb02ad 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -17,6 +17,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" +import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -25,6 +26,15 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -230,6 +240,16 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) { + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s }), diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md new file mode 100644 index 00000000000..b7b4be1b9fb --- /dev/null +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -0,0 +1,353 @@ + + +# Customizing opencode + +opencode validates its own config strictly and refuses to start when a field +is wrong. The shapes below are the accepted shapes. When in doubt, fetch +`https://opencode.ai/config.json` (the JSON Schema) and validate against it. + +Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"` +so the user's editor catches mistakes as they type. + +## Where files live + +| Scope | Path | +|---|---| +| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | +| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | +| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | +| Global agents | `~/.config/opencode/agent(s)/.md` | +| Project skills | `.opencode/skill(s)//SKILL.md` | +| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | +| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | + +Configs from each scope are deep-merged. Project overrides global. Unknown +top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. + +## opencode.json + +Every field is optional. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "username": "string", + "model": "provider/model-id", + "small_model": "provider/model-id", + "default_agent": "agent-name", + "shell": "/bin/zsh", + "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", + "share": "manual" | "auto" | "disabled", + "autoupdate": true | false | "notify", + "snapshot": true, + "instructions": ["AGENTS.md", "docs/style.md"], + + "skills": { + "paths": [".opencode/skills", "/abs/path/to/skills"], + "urls": ["https://example.com/.well-known/skills/"] + }, + + "agent": { + "my-agent": { + "model": "anthropic/claude-sonnet-4-6", + "mode": "subagent", + "description": "...", + "permission": { "edit": "deny" } + } + }, + + "command": { + "deploy": { "description": "...", "prompt": "..." } + }, + + "provider": { + "anthropic": { "options": { "apiKey": "..." } } + }, + "disabled_providers": ["openai"], + "enabled_providers": ["anthropic"], + + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": {} + }, + "remote-thing": { + "type": "remote", + "url": "https://...", + "headers": { "Authorization": "Bearer ..." } + } + }, + + "plugin": [ + "opencode-gemini-auth", + "opencode-foo@1.2.3", + "./local-plugin.ts", + ["opencode-bar", { "option": "value" }] + ], + + "permission": { + "edit": "deny", + "bash": { "git *": "allow", "*": "ask" } + }, + + "formatter": false, + "lsp": false, + + "experimental": { + "primary_tools": ["edit"], + "mcp_timeout": 30000 + }, + + "tool_output": { "max_lines": 200, "max_bytes": 8192 }, + + "compaction": { "auto": true, "tail_turns": 15 } +} +``` + +Shape notes worth being explicit about: + +- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. +- `skills` is an object with `paths` and/or `urls`, not an array. +- `agent` is an object keyed by agent name, not an array. +- `plugin` is an array of strings or `[name, options]` tuples, not an object. +- `mcp[name].command` is an array of strings, never a single string. `type` is required. +- `permission` is either a string action or an object keyed by tool name. + +## Skills + +opencode's skill loader scans for `**/SKILL.md` inside skill directories. The +file is named `SKILL.md` exactly, and lives in its own folder named after the +skill: + +``` +.opencode/skills/my-skill/SKILL.md +``` + +Frontmatter: + +```markdown +--- +name: my-skill +description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say. +--- + +# My Skill + +(skill body in markdown: instructions, examples, references) +``` + +- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. +- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both *what* the skill does and *when* to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. +- Optional: `license`, `compatibility`, `metadata` (string-string map). + +Register skills from non-default locations via `skills.paths` (scanned +recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of +skills). + +## Agents + +Two ways to define an agent. Use the file form for anything non-trivial. + +### Inline (in `opencode.json`) + +```json +{ + "agent": { + "my-reviewer": { + "description": "Reviews PRs for style violations.", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-6", + "permission": { "edit": "deny", "bash": "ask" }, + "prompt": "You are a strict PR reviewer..." + } + } +} +``` + +### File + +``` +.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md +``` + +```markdown +--- +description: Reviews PRs for style violations. +mode: subagent +model: anthropic/claude-sonnet-4-6 +permission: + edit: deny + bash: ask +--- + +You are a strict PR reviewer. Focus on... +``` + +The file body becomes the agent's `prompt`. Do not also put `prompt:` in the +frontmatter. + +`mode` is one of `"primary"`, `"subagent"`, `"all"`. + +Allowed top-level frontmatter fields: `name, model, variant, description, mode, +hidden, color, steps, options, permission, disable, temperature, top_p`. Any +unknown field is silently routed into `options`. + +To disable a built-in agent: `agent: { build: { disable: true } }`, or in a +file, `disable: true` in frontmatter. + +`default_agent` must point to a non-hidden, primary-mode agent. + +### Built-in agents + +opencode ships with `build`, `plan`, `general`, `explore`, plus optionally +`scout` (gated on `OPENCODE_EXPERIMENTAL_SCOUT`). Hidden internal agents: +`compaction`, `title`, `summary`. To override a built-in's fields, define the +same key in `agent: { : { ... } }`. + +## Plugins + +`plugin:` is an array. Each entry is one of: + +```json +"plugin": [ + "opencode-gemini-auth", // npm spec, latest + "opencode-foo@1.2.3", // npm spec, pinned + "./local-plugin.ts", // file path, relative to the declaring config + "file:///abs/path/plugin.js", // file URL + ["opencode-bar", { "key": "val" }] // tuple form with options +] +``` + +Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in +`.opencode/plugin/` or `.opencode/plugins/`. + +A plugin module exports `default` (or any named export) of type +`Plugin = (input: PluginInput, options?) => Promise`. The export is a +function, not a plain object literal, and the function returns an object +(return `{}` if there is nothing to register). + +```ts +import type { Plugin } from "@opencode-ai/plugin" + +export default (async ({ client, project, directory, $ }) => { + return { + config: (cfg) => { + // cfg is the live merged config; mutate fields here. + }, + "tool.execute.before": async (input, output) => { + // mutate output.args before the tool runs + }, + } +}) satisfies Plugin +``` + +Hook surface (mutate `output` in place; return `void`): +- `event(input)`: every bus event +- `config(cfg)`: once on init with the merged config +- `chat.message`, `chat.params`, `chat.headers` +- `tool.execute.before`, `tool.execute.after` +- `tool.definition` +- `command.execute.before` +- `shell.env` +- `permission.ask` +- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, + `experimental.session.compacting`, `experimental.compaction.autocontinue`, + `experimental.text.complete` + +Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, +`auth: { ... }`, `provider: { ... }`. + +## MCP servers + +`mcp:` is an object keyed by server name. Each server is discriminated by +`type`: + +```json +{ + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": { "BROWSER": "chromium" } + }, + "github": { + "type": "remote", + "url": "https://...", + "enabled": true, + "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } + }, + "old-server": { "enabled": false } + } +} +``` + +`command` is an array of strings. `type` is required. Use `enabled: false` to +disable a server inherited from a parent config. + +## Permissions + +```json +"permission": { + "edit": "deny", + "bash": { "git *": "allow", "rm *": "deny", "*": "ask" }, + "external_directory": { "~/secrets/**": "deny", "*": "allow" } +} +``` + +Actions: `"allow"`, `"ask"`, `"deny"`. + +Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an +object `{ pattern: action }`. Within an object, **insertion order matters**. +opencode evaluates the LAST matching rule, so put broad rules first and narrow +rules last. + +`permission: "allow"` (a string at the top level) is shorthand for "allow +everything" and is rarely what the user wants. + +Known permission keys: `read, edit, glob, grep, list, bash, task, +external_directory, todowrite, question, webfetch, websearch, codesearch, +repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, +question, webfetch, websearch, codesearch, doom_loop`) only accept a flat +action, not a per-pattern object. + +`external_directory` patterns are filesystem paths (use `~/`, absolute paths, +or globs like `~/projects/**`). + +Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on +the `plan` agent's permission ruleset (`edit: deny *`). + +## Escape hatches + +When a user's config is broken and opencode won't start, these env vars help: + +- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` + and start from globals only. Run from the project directory, opencode loads, + the user edits the broken file, then they restart without the flag. +- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config. +- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: + inject inline JSON as a final local-scope merge. +- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. +- `OPENCODE_PURE=1`: skip external plugins entirely. +- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`, + `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under + `~/.claude/` and `~/.agents/`. + +## When proposing edits + +- Validate against the schema before writing. If you are unsure of a field's + exact shape, fetch `https://opencode.ai/config.json` rather than guessing. +- Preserve `$schema` and any existing fields the user did not ask to change. +- For agent, skill, and plugin definitions, prefer creating new files in the + correct location over inlining everything in `opencode.json`. +- If the user's existing config is malformed, point them at the env-var escape + hatch above so they can edit from inside opencode without breaking their + session. +- opencode hard-fails on invalid config by design. There is no graceful + degradation, so get the shape right the first time. diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index b408f7ef11b..1ba0554d3ee 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,6 +35,11 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +// Tests assert exact skill counts from disk discovery; the built-in +// customize-opencode skill is opt-in for stable channels and on by default +// for unstable channels (including "local" where CI runs). Disable it here +// so disk-discovery tests aren't off-by-one. +process.env["OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills From d80e1199cafdd85f8154e96ec572e52a4891e5a9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 02:06:39 +0000 Subject: [PATCH 041/201] chore: generate --- .../src/skill/prompt/customize-opencode.md | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md index b7b4be1b9fb..6158aae085f 100644 --- a/packages/opencode/src/skill/prompt/customize-opencode.md +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -15,15 +15,15 @@ so the user's editor catches mistakes as they type. ## Where files live -| Scope | Path | -|---|---| -| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | -| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | -| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | -| Global agents | `~/.config/opencode/agent(s)/.md` | -| Project skills | `.opencode/skill(s)//SKILL.md` | -| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | -| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | +| Scope | Path | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | +| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | +| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | +| Global agents | `~/.config/opencode/agent(s)/.md` | +| Project skills | `.opencode/skill(s)//SKILL.md` | +| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | +| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | Configs from each scope are deep-merged. Project overrides global. Unknown top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. @@ -143,7 +143,7 @@ description: One sentence covering what this skill does AND when to trigger it. ``` - `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. -- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both *what* the skill does and *when* to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. +- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. - Optional: `license`, `compatibility`, `metadata` (string-string map). Register skills from non-default locations via `skills.paths` (scanned @@ -248,6 +248,7 @@ export default (async ({ client, project, directory, $ }) => { ``` Hook surface (mutate `output` in place; return `void`): + - `event(input)`: every bus event - `config(cfg)`: once on init with the merged config - `chat.message`, `chat.params`, `chat.headers` From c6e6bdf59f8a5488f57ec9a521aa3a15e11e14ea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:10:44 -0400 Subject: [PATCH 042/201] fix(session): tolerate negative token counts in stored parts (#26620) --- packages/opencode/src/session/message-v2.ts | 24 ++--- packages/opencode/src/session/message.ts | 10 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/v2/session-event.ts | 10 +- .../server/negative-tokens-regression.test.ts | 97 +++++++++++++++++++ 5 files changed, 120 insertions(+), 23 deletions(-) create mode 100644 packages/opencode/test/server/negative-tokens-regression.test.ts diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 23a01a17043..3db0b306839 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -270,13 +270,13 @@ export const StepFinishPart = Schema.Struct({ snapshot: Schema.optional(Schema.String), cost: Schema.Finite, tokens: Schema.Struct({ - total: Schema.optional(NonNegativeInt), - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), }) @@ -554,13 +554,13 @@ export const Assistant = Schema.Struct({ summary: Schema.optional(Schema.Boolean), cost: Schema.Finite, tokens: Schema.Struct({ - total: Schema.optional(NonNegativeInt), - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), structured: Schema.optional(Schema.Any), diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 12952a87b94..32a815db14f 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -172,12 +172,12 @@ export const Info = Schema.Struct({ cost: Schema.Finite, summary: Schema.optional(Schema.Boolean), tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), }), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 510d82a7313..a8cfe27a470 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -353,7 +353,7 @@ export function plan(input: { slug: string; time: { created: number } }, instanc export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 - return value + return Math.max(0, value) } const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index f439b1f8418..d9400c3534b 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -118,12 +118,12 @@ export namespace Step { finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), snapshot: Schema.String.pipe(Schema.optional), diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts new file mode 100644 index 00000000000..77ad1bc279a --- /dev/null +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -0,0 +1,97 @@ +// Regression: a stored step-finish part with a negative token count made the +// messages endpoint 400. Some providers reported `outputTokens` excluding +// reasoning while also reporting `reasoningTokens` separately, so the +// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to +// negative. The pre-fix `safe()` clamp only guarded against non-finite. The +// strict `NonNegativeInt` schema then made every load of the message list +// fail to encode, killing Desktop boot for every user with such a row. +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { MessageID, PartID } from "../../src/session/schema" +import * as Database from "@/storage/db" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function seedNegativeTokenSession(directory: string) { + return Effect.promise(async () => + WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }), + ) +} + +describe("messages endpoint tolerates legacy negative token counts", () => { + it.live( + "returns 200 even when a step-finish part has tokens.output < 0", + Effect.acquireRelease( + Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const sessionID = yield* seedNegativeTokenSession(tmp.path) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + ), + ), + ) +}) From 29250a0efbb9bba43dcb4244716a41648ecd611f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:10:48 -0400 Subject: [PATCH 043/201] fix(session): loosen remaining stored numeric schemas to tolerate legacy data (#26622) --- packages/opencode/src/project/vcs.ts | 8 ++++---- packages/opencode/src/session/message-v2.ts | 4 ++-- packages/opencode/src/session/session.ts | 6 +++--- packages/opencode/src/snapshot/index.ts | 4 ++-- packages/opencode/src/v2/session-event.ts | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 21ee882c41f..092444c444c 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -234,8 +234,8 @@ export const FileDiff = Schema.Struct({ // populates patch, but loosening matches the sibling schema so a // future code path that omits it can't crash /instance/vcs/diff. patch: Schema.optional(Schema.String), - additions: NonNegativeInt, - deletions: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) @@ -244,8 +244,8 @@ export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ file: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "VcsFileStatus" }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3db0b306839..3eb6f07b82b 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable Date: Sun, 10 May 2026 02:11:45 +0000 Subject: [PATCH 044/201] chore: generate --- packages/sdk/openapi.json | 126 +++++++++++++------------------------- 1 file changed, 42 insertions(+), 84 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c6839668817..8e7d56a9874 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9200,12 +9200,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -10047,31 +10045,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10255,12 +10247,10 @@ "type": "string" }, "start": { - "type": "integer", - "minimum": 0 + "type": "number" }, "end": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["value", "start", "end"], @@ -10643,31 +10633,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10935,16 +10919,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -12767,16 +12748,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -13094,12 +13072,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -13119,12 +13095,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -14247,16 +14221,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -14841,27 +14812,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -15579,8 +15545,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" @@ -17083,27 +17048,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -17691,8 +17651,7 @@ "type": "string" }, "statusCode": { - "type": "integer", - "minimum": 0 + "type": "number" }, "isRetryable": { "type": "boolean" @@ -17736,8 +17695,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" From d802b0a277f4e7f113b5efd8d5446fc1db22f4a4 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 10 May 2026 02:34:25 +0000 Subject: [PATCH 045/201] release: v1.14.46 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 22ad0279a7c..9dcad8f126c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.45", + "version": "1.14.46", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.45", + "version": "1.14.46", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 16d1f6b10dc..635614bf13a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.45", + "version": "1.14.46", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 1ec8d79a66a..9c7cbbc3bfc 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 2c7f23d6df1..fa9d8fe15c5 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.45", + "version": "1.14.46", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index a5f01a32ab3..a24da8fa1d6 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.45", + "version": "1.14.46", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index c522a5f2831..b5015ec22a7 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index a68a1d3f7af..1567df4b852 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.45", + "version": "1.14.46", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1d3f9b961b1..9a1d15ea127 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7c7cc1ed24e..b02d14c3b19 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.45", + "version": "1.14.46", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7d84cd37302..061cd6ab386 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.45" +version = "1.14.46" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 731e8145010..90ad91f89df 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.45", + "version": "1.14.46", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ff23648540a..9cf71a7436d 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.45", + "version": "1.14.46", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 4d2c2fdfbf2..8fa5e833e6b 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.45", + "version": "1.14.46", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f55b75c0d26..dbd766f934a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.45", + "version": "1.14.46", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5e7deabcdf7..e25f89338a0 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index bd881db4515..3faace81cd2 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index c30e9c3b549..5f4aa7eebaa 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 75a85094005..5301e380322 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 47f1d91e856..490fea2aff7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.45", + "version": "1.14.46", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index f04148b8814..14301928955 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.45", + "version": "1.14.46", "publisher": "sst-dev", "repository": { "type": "git", From dfcb6235151988ab0a98ac272fa279bb00d27476 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 28 May 2026 15:11:45 -0400 Subject: [PATCH 046/201] test(jetbrains): stabilize session controller construction --- .../ai/kilocode/client/session/SessionUi.kt | 8 +++++++- .../controller/SessionControllerTestBase.kt | 20 +++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 19628c239a0..8f8ca9a1094 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -96,7 +96,13 @@ class SessionUi( ?: EVENT_FLUSH_MS private val controller = SessionController( - this, ref, sessions, workspace, app, cs, comp = this, + parent = this, + ref = ref, + sessions = sessions, + workspace = workspace, + app = app, + cs = cs, + comp = this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), displayMs = displayMs, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 4b0a377c6d7..6b894047e90 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -154,16 +154,16 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { ): SessionController { val root = Root() val m = SessionController( - parent, - ref, - sessions, - workspace, - app, - scope, - root, - flushMs, - condense, - displayMs, + parent = parent, + ref = ref, + sessions = sessions, + workspace = workspace, + app = app, + cs = scope, + comp = root, + flushMs = flushMs, + condense = condense, + displayMs = displayMs, open = open, beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, From 076d8ab1366a1c33f5c5eb944d1e5c9faf288d0c Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 15:26:02 -0400 Subject: [PATCH 047/201] feat(jetbrains): add empty session feedback UI --- .changeset/stellar-forest-feedback.md | 5 + .kilo/plans/1780256109130-stellar-forest.md | 109 ++++++++++ .../ai/kilocode/client/session/SessionUi.kt | 2 +- .../session/ui/empty/EmptySessionFeedback.kt | 117 +++++++++++ .../ui/{ => empty}/EmptySessionPanel.kt | 192 ++++------------- .../client/session/ui/empty/RecentsList.kt | 198 ++++++++++++++++++ .../kotlin/ai/kilocode/client/ui/UiStyle.kt | 14 ++ .../src/main/resources/icons/discord.svg | 3 + .../src/main/resources/icons/discord_dark.svg | 3 + .../resources/messages/KiloBundle.properties | 5 + .../messages/KiloBundle_ar.properties | 5 + .../messages/KiloBundle_bs.properties | 5 + .../messages/KiloBundle_da.properties | 5 + .../messages/KiloBundle_de.properties | 5 + .../messages/KiloBundle_es.properties | 5 + .../messages/KiloBundle_fr.properties | 5 + .../messages/KiloBundle_ja.properties | 5 + .../messages/KiloBundle_ko.properties | 5 + .../messages/KiloBundle_nl.properties | 5 + .../messages/KiloBundle_no.properties | 5 + .../messages/KiloBundle_pl.properties | 5 + .../messages/KiloBundle_pt_BR.properties | 5 + .../messages/KiloBundle_ru.properties | 5 + .../messages/KiloBundle_th.properties | 5 + .../messages/KiloBundle_tr.properties | 5 + .../messages/KiloBundle_uk.properties | 5 + .../messages/KiloBundle_zh_CN.properties | 5 + .../messages/KiloBundle_zh_TW.properties | 5 + .../client/session/SessionUiFactoryTest.kt | 4 +- .../client/session/SessionUiLayoutTest.kt | 2 +- .../session/ui/EmptySessionPanelTest.kt | 48 ++++- 31 files changed, 631 insertions(+), 161 deletions(-) create mode 100644 .changeset/stellar-forest-feedback.md create mode 100644 .kilo/plans/1780256109130-stellar-forest.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/{ => empty}/EmptySessionPanel.kt (54%) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg diff --git a/.changeset/stellar-forest-feedback.md b/.changeset/stellar-forest-feedback.md new file mode 100644 index 00000000000..98dd0d17c1d --- /dev/null +++ b/.changeset/stellar-forest-feedback.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add Feedback & Support to the JetBrains empty session screen. diff --git a/.kilo/plans/1780256109130-stellar-forest.md b/.kilo/plans/1780256109130-stellar-forest.md new file mode 100644 index 00000000000..3aafa6c0dd9 --- /dev/null +++ b/.kilo/plans/1780256109130-stellar-forest.md @@ -0,0 +1,109 @@ +# Plan: JetBrains Feedback & Support Button + +## Goal +Add the VS Code empty-state `Feedback & Support` affordance to the JetBrains plugin, including a non-modal popup with the same three destinations: + +- GitHub issues: `https://github.com/Kilo-Org/kilocode/issues/new/choose` +- Discord: `https://kilo.ai/discord` +- Customer support: `https://kilo.ai/support` + +The popup must hide when clicking outside or pressing Escape. + +## Findings + +- The JetBrains empty state is `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt`. +- It already renders the logo, welcome copy, recents, and `Show History`; this is the right place to add the button. +- The VS Code implementation is: + - Button: `packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx` + - Popup content: `packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx` + - Styling: `packages/kilo-vscode/webview-ui/src/styles/welcome.css` +- Local IntelliJ API source is available at `$INTELLIJ_REPO=/Users/kirillk/products/intellij-community`. +- IntelliJ popup API confirmed in local source: + - `JBPopupFactory.getInstance().createComponentPopupBuilder(content, focusComponent)` + - `setModalContext(false)` for non-modal context + - `setRequestFocus(false)` / `setFocusable(false)` if we want the popup not to steal focus + - `setCancelOnClickOutside(true)` for outside-click dismissal + - `setCancelKeyEnabled(true)` for Escape dismissal + - `setCancelOnWindowDeactivation(true)` and `setCancelOnOtherWindowOpen(true)` are available and appropriate for cleanup +- IntelliJ platform icons found in `AllIcons`: + - Feedback button: `AllIcons.Ide.Feedback` + - GitHub action: `AllIcons.Vcs.Vendors.Github` + - Support/help action: `AllIcons.Actions.Help` or `AllIcons.General.ContextHelp` + - Discord: no platform icon found. Add a Discord icon by borrowing the existing VS Code/kilo-ui Discord SVG artwork and adapting it into the JetBrains plugin resources with IntelliJ-compatible SVG colors and dark variant if needed. + +## Implementation Steps + +1. Add localized JetBrains bundle keys. + - Add base English strings to `frontend/src/main/resources/messages/KiloBundle.properties`: + - `feedback.button=Feedback & Support` + - `feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing.` + - `feedback.dialog.github=Report an issue on GitHub` + - `feedback.dialog.discord=Join our Discord community` + - `feedback.dialog.support=Customer Support` + - Reuse existing cancel text if present; otherwise add `common.cancel=Cancel` only if needed. + - Add matching keys to localized `KiloBundle_*.properties` files. If accurate translations are not already available from the VS Code i18n files, copy the VS Code translations for matching locales where practical and leave English fallback only if the JetBrains bundle mechanism supports it cleanly. + +2. Extend `EmptySessionPanel` UI. + - Add a new retained `FeedbackButton`, styled to match VS Code conceptually: + - feedback icon + `Feedback & Support` text + - dashed link-colored border + - transparent background + - hand cursor + - hover state fills with link color and flips foreground/background for contrast + - Use IntelliJ theme APIs instead of raw colors: + - link color: `JBUI.CurrentTheme.Link.Foreground.ENABLED` + - hover link color: `JBUI.CurrentTheme.Link.Foreground.HOVERED` if useful + - editor/panel background from existing session style or `UiStyle.Colors.editorBackground()` / component background + - spacing via `UiStyle.Gap` / `JBUI.Borders` + - rounded arc via `UiStyle.Arc.component()` or `JBUI.getInt("Button.arc", 6)` at paint time + - Add the feedback button below the existing `Show History` button in the empty-state south area, keeping the current centered layout. + - Keep Swing mutations on EDT and preserve retained-component behavior. + +3. Implement the feedback popup in frontend Swing code. + - Add a small popup builder method/class inside `EmptySessionPanel.kt` or a new nearby UI file if the file would become too large. + - Content should mirror VS Code: + - Kilo logo at top using existing `/icons/kilo-content.svg` + - message text + - three action buttons/rows for GitHub, Discord, and Customer Support + - every action should have an icon: `AllIcons.Vcs.Vendors.Github` for GitHub, the borrowed Discord SVG for Discord, and `AllIcons.Actions.Help` or `AllIcons.General.ContextHelp` for Customer Support + - optional Cancel button if it fits the JetBrains UX; Escape/outside click already dismisses + - Add the Discord icon asset under `frontend/src/main/resources/icons/` using the plugin's existing icon naming pattern, for example `discord.svg` and `discord_dark.svg` if the borrowed asset needs separate theme variants. + - Load the Discord icon with `IconLoader.getIcon("/icons/discord.svg", EmptySessionPanel::class.java)` or a small Kilo-owned icon object if multiple call sites need it. + - Use `BrowserUtil.browse(url)` to open links. + - Close the popup after opening a URL. + - Use `JBPopupFactory.createComponentPopupBuilder(content, null)` and configure: + - `.setModalContext(false)` + - `.setRequestFocus(false)` + - `.setFocusable(false)` unless keyboard tabbing inside popup is desired; if buttons need keyboard focus, use `.setFocusable(true)` with `.setRequestFocus(false)` + - `.setCancelOnClickOutside(true)` + - `.setCancelKeyEnabled(true)` + - `.setCancelOnWindowDeactivation(true)` + - `.setCancelOnOtherWindowOpen(true)` + - `.setResizable(false)` + - `.setMovable(false)` + - Show with `popup.showUnderneathOf(feedbackButton)`. + +4. Add test coverage in `EmptySessionPanelTest.kt`. + - Assert feedback button text uses `KiloBundle.message("feedback.button")`. + - Assert feedback button has hand cursor and visible border semantics. + - Add an injectable browse callback or popup action callback only if needed for testing without launching browsers; keep it minimal and avoid production-only test hooks if component traversal can exercise enough behavior. + - Add a test that clicking the feedback button creates/shows popup content, if feasible in `BasePlatformTestCase`; otherwise test the retained popup content builder/action rows directly through package-internal methods. + - Assert the three action labels exist and URL callbacks map to the same URLs as VS Code. + - Assert the Discord action has an icon, so the plan cannot regress to a text-only Discord row. + +5. Add release note. + - This is user-facing for the JetBrains plugin, so add a patch changeset under `.changeset/` unless the repo has a JetBrains-specific release-note mechanism that supersedes changesets. + - Suggested text: `Add Feedback & Support to the JetBrains empty session screen.` + +6. Verify. + - Run the focused frontend/JetBrains tests first, preferably from `packages/kilo-jetbrains/`: + - `./gradlew :frontend:test --tests "ai.kilocode.client.session.ui.EmptySessionPanelTest"` + - Run JetBrains typecheck from `packages/kilo-jetbrains/`: + - `bun run typecheck` or `./gradlew typecheck` + - If touching only frontend UI and tests pass, no backend/SDK generation is needed. + +## Notes + +- Do not add Kotlin UI DSL, Compose, or JCEF. +- Do not modify shared opencode files; this work is entirely under `packages/kilo-jetbrains/` plus a changeset. +- Prefer platform icons where available, but explicitly borrow/add the Discord icon because `AllIcons` does not provide one and the popup should match the VS Code button set visually. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 133af6862f3..8a4439e31c8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -12,7 +12,7 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.scroll.SessionScroll import ai.kilocode.client.session.ui.ConnectionPanel -import ai.kilocode.client.session.ui.EmptySessionPanel +import ai.kilocode.client.session.ui.empty.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt new file mode 100644 index 00000000000..48ca0fcc3e4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt @@ -0,0 +1,117 @@ +package ai.kilocode.client.session.ui.empty + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +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 com.intellij.icons.AllIcons +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.UIUtil +import com.intellij.xml.util.XmlStringUtil +import java.awt.Cursor +import java.awt.Point +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JComponent + +internal class EmptySessionFeedback( + private val browse: (String) -> Unit, +) { + val button: JButton = FeedbackButton().apply { + addActionListener { popup() } + } + + @RequiresEdt + private fun popup() { + lateinit var balloon: Balloon + val content = content { url -> + browse(url) + balloon.hide() + } + val point = RelativePoint(button, Point(button.width / 2, button.height)) + balloon = JBPopupFactory.getInstance() + .createBalloonBuilder(content) + .setHideOnClickOutside(true) + .setHideOnKeyOutside(true) + .setHideOnAction(true) + .setHideOnFrameResize(true) + .setBorderColor(UiStyle.Balloon.border()) + .setFillColor(UiStyle.Balloon.bg()) + .setBorderInsets(UiStyle.Balloon.insets()) + .setPointerSize(UiStyle.Balloon.pointer()) + .setCornerRadius(UiStyle.Balloon.arc()) + .createBalloon() + + balloon.show(point, Balloon.Position.below) + } + + private class FeedbackButton : EmptySessionPanel.ShowHistoryButton(buttonHtml(), AllIcons.Ide.Feedback) + + private class ActionButton(text: String, icon: javax.swing.Icon, action: () -> Unit) : JButton(text, icon) { + init { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addActionListener { action() } + } + } + + companion object { + @RequiresEdt + fun content(open: (String) -> Unit): JComponent { + val logo = JBLabel(IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java)).apply { + horizontalAlignment = JBLabel.CENTER + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + open(KILO_URL) + } + }) + } + val msg = JBLabel(messageHtml()).apply { + foreground = UIUtil.getLabelForeground() + horizontalAlignment = JBLabel.CENTER + } + val actions = Stack.vertical(gap = UiStyle.Gap.sm()) + .next(ActionButton(KiloBundle.message("feedback.dialog.github"), AllIcons.Vcs.Vendors.Github) { + open(GITHUB_ISSUES_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + .next(ActionButton(KiloBundle.message("feedback.dialog.discord"), DISCORD_ICON) { + open(DISCORD_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + .next(ActionButton(KiloBundle.message("feedback.dialog.support"), AllIcons.Actions.Help) { + open(SUPPORT_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + + return Stack.vertical(gap = UiStyle.Gap.lg()) + .fill(UiStyle.Gap.sm()) + .next(logo.align(HAlign.CENTER, VAlign.CENTER)) + .next(msg.align(HAlign.CENTER, VAlign.CENTER)) + .fill(UiStyle.Gap.xs()) + .next(actions.align(HAlign.CENTER, VAlign.CENTER)) + .fill(UiStyle.Gap.xs()) + } + + fun urls() = listOf(GITHUB_ISSUES_URL, DISCORD_URL, SUPPORT_URL) + + private fun messageHtml() = XmlStringUtil.wrapInHtml( + "
${XmlStringUtil.escapeString(KiloBundle.message("feedback.dialog.message"))}
" + ) + + private fun buttonHtml() = XmlStringUtil.wrapInHtml( + XmlStringUtil.escapeString(KiloBundle.message("feedback.button")) + ) + + private const val KILO_URL = "https://kilocode.ai" + private const val GITHUB_ISSUES_URL = "https://github.com/Kilo-Org/kilocode/issues/new/choose" + private const val DISCORD_URL = "https://kilo.ai/discord" + private const val SUPPORT_URL = "https://kilo.ai/support" + private val DISCORD_ICON = IconLoader.getIcon("/icons/discord.svg", EmptySessionPanel::class.java) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt similarity index 54% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index 7f8283a7067..bd91413f63a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -1,31 +1,25 @@ -package ai.kilocode.client.session.ui +package ai.kilocode.client.session.ui.empty import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind -import ai.kilocode.client.session.SessionRef -import ai.kilocode.client.session.history.HistoryActivitySnapshot -import ai.kilocode.client.session.history.HistoryTime -import ai.kilocode.client.session.history.LocalHistoryItem -import ai.kilocode.client.session.history.itemAt -import ai.kilocode.client.session.history.title +import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.controller.SessionController -import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Align 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.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.util.concurrency.annotations.RequiresEdt import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBList import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -35,19 +29,14 @@ import java.awt.BorderLayout import java.awt.Component import java.awt.Cursor import java.awt.Dimension -import java.awt.FlowLayout import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints import java.awt.event.HierarchyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import java.awt.event.MouseMotionAdapter -import javax.swing.DefaultListModel import javax.swing.JButton -import javax.swing.JList -import javax.swing.ListCellRenderer -import javax.swing.ListSelectionModel +import javax.swing.JComponent import javax.swing.Timer /** @@ -63,51 +52,20 @@ class EmptySessionPanel( private val history: () -> Unit = {}, private val activity: () -> Map = { emptyMap() }, private val titles: () -> Map = { emptyMap() }, + private val browse: (String) -> Unit = BrowserUtil::browse, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { val view: Align = align(HAlign.CENTER, VAlign.CENTER) - private val model = DefaultListModel() - private var hover = -1 private var style = SessionEditorStyle.current() - private var snapshot = HistoryActivitySnapshot() private val timer = Timer(ACTIVITY_MS) { syncActivity() } - - private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply { - foreground = UIUtil.getContextHelpForeground() - } - - private val list = JBList(model).apply { - isOpaque = false - selectionMode = ListSelectionModel.SINGLE_SELECTION - visibleRowCount = SessionUiStyle.RecentSessions.LIMIT - cellRenderer = SessionRenderer() - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - emptyText.clear() - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - val item = itemAt(this@apply, e) ?: return - controller.openSession(SessionRef.Local(item.session)) - } - - override fun mouseExited(e: MouseEvent) { - hover = -1 - repaint() - } - }) - addMouseMotionListener(object : MouseMotionAdapter() { - override fun mouseMoved(e: MouseEvent) { - val index = index(e) - if (hover == index) return - hover = index - repaint() - } - }) - } + private val recent = RecentsList(recents, controller) private val historyButton = ShowHistoryButton().apply { addActionListener { history() } } + private val feedback = EmptySessionFeedback(browse) + private val welcomeLabel = JBLabel(welcomeHtml()).apply { foreground = UIUtil.getContextHelpForeground() horizontalAlignment = JBLabel.CENTER @@ -134,7 +92,6 @@ class EmptySessionPanel( Disposer.register(parent, this) isOpaque = false applyStyle(SessionEditorStyle.current()) - setSessions(recents) addHierarchyListener { e -> if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener if (isShowing) { @@ -159,40 +116,28 @@ class EmptySessionPanel( add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) } - val recent = BorderLayoutPanel().apply { - isOpaque = false - add(recentTitle, BorderLayout.NORTH) - add(list, BorderLayout.CENTER) - } - val south = BorderLayoutPanel().apply { isOpaque = false - add(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL), BorderLayout.CENTER) + add(Stack.vertical(gap = UiStyle.Gap.lg()) + .next(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL)) + .next(Centerizer(feedback.button, Centerizer.TYPE.HORIZONTAL)), BorderLayout.CENTER) } add(header, BorderLayout.NORTH) - add(recent, BorderLayout.CENTER) + if (recent.hasSessions()) add(recent, BorderLayout.CENTER) add(south, BorderLayout.SOUTH) } - private fun setSessions(sessions: List) { - model.clear() - sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement) - revalidate() - repaint() - } - - internal fun recentCount() = model.size() + internal fun recentCount() = recent.count() internal fun selectRecent(index: Int) { - list.selectedIndex = index + recent.select(index) } - internal fun selectedRecent() = list.selectedIndex + internal fun selectedRecent() = recent.selected() internal fun clickRecent(index: Int) { - list.selectedIndex = index - controller.openSession(SessionRef.Local(model.getElementAt(index).session)) + recent.click(index) } internal fun clickShowHistory() { @@ -201,13 +146,25 @@ class EmptySessionPanel( internal fun showHistoryText() = historyButton.text + internal fun feedbackText() = KiloBundle.message("feedback.button") + + internal fun feedbackCursor() = feedback.button.cursor.type + + internal fun feedbackIcon() = feedback.button.icon + + internal fun feedbackBorderPainted() = feedback.button.isBorderPainted + + internal fun feedbackContent(open: (String) -> Unit = {}): JComponent = EmptySessionFeedback.content(open) + + internal fun feedbackUrls() = EmptySessionFeedback.urls() + internal fun showHistoryBorderPainted() = historyButton.isBorderPainted internal fun showHistoryCursor() = historyButton.cursor.type - internal fun recentCursor() = list.cursor.type + internal fun recentCursor() = recent.cursorType() - internal fun recentVisible() = true + internal fun recentVisible() = recent.hasSessions() internal fun explanationText() = KiloBundle.message("session.empty.welcome") @@ -226,92 +183,25 @@ class EmptySessionPanel( internal fun activeView() = getComponent(0) internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = - HistoryTime.relative(LocalHistoryItem(session), now) + recent.text(session, now) internal fun rendererComponent( session: SessionDto, selected: Boolean = false, hover: Boolean = false, ): Component { - val old = this.hover - this.hover = if (hover) 0 else -1 - return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also { - this.hover = old - } + return recent.renderer(session, selected, hover) } @RequiresEdt internal fun syncActivity() { - val next = HistoryActivitySnapshot(activity(), titles()) - val changed = snapshot.changed(next) - snapshot = next - repaintRows(changed) + recent.sync(activity(), titles()) } - private fun repaintRows(ids: Set) { - if (ids.isEmpty()) return - repeat(model.size()) { index -> - if (model.getElementAt(index).id !in ids) return@repeat - list.getCellBounds(index, index)?.let(list::repaint) - } - } - - private fun index(e: MouseEvent): Int { - val idx = list.locationToIndex(e.point) - if (idx < 0) return -1 - val box = list.getCellBounds(idx, idx) ?: return -1 - if (!box.contains(e.point)) return -1 - return idx - } - - private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer { - private val title = JBLabel() - private val badge = JBLabel().apply { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - } - private val time = JBLabel() - private val head = BorderLayoutPanel().apply { - add(BorderLayoutPanel().apply { - layout = FlowLayout(FlowLayout.LEFT, 0, 0) - isOpaque = false - add(title) - add(badge) - }, BorderLayout.CENTER) - } - - init { - layout = BorderLayout(UiStyle.Gap.pad(), 0) - border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) - head.isOpaque = false - add(head, BorderLayout.CENTER) - add(time, BorderLayout.EAST) - } - - override fun getListCellRendererComponent( - list: JList, - value: LocalHistoryItem?, - index: Int, - selected: Boolean, - focus: Boolean, - ): Component { - val over = selected || hover == index - isOpaque = over - background = if (over) list.selectionBackground else list.background - title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground() - time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground() - title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: "" - time.text = value?.let(HistoryTime::relative) ?: "" - setBadge(value?.id?.let(snapshot.activity::get)) - return this - } - - private fun setBadge(kind: SessionActivityKind?) { - badge.isVisible = kind != null - badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) } - } - } - - private inner class ShowHistoryButton : JButton(KiloBundle.message("session.showHistory"), AllIcons.Vcs.History) { + internal open class ShowHistoryButton( + text: String = KiloBundle.message("session.showHistory"), + icon: javax.swing.Icon = AllIcons.Vcs.History, + ) : JButton(text, icon) { private var over = false init { @@ -361,7 +251,7 @@ class EmptySessionPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style welcomeLabel.font = style.regularFont - recentTitle.font = style.smallFont + recent.titleFont(style.smallFont) revalidate() repaint() } @@ -374,7 +264,3 @@ class EmptySessionPanel( const val ACTIVITY_MS = 3_000 } } - -private fun Map.changed(next: Map) = (keys + next.keys).filterTo(mutableSetOf()) { - this[it] != next[it] -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt new file mode 100644 index 00000000000..c3205b69cf8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt @@ -0,0 +1,198 @@ +package ai.kilocode.client.session.ui.empty + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.history.HistoryActivitySnapshot +import ai.kilocode.client.session.history.HistoryTime +import ai.kilocode.client.session.history.LocalHistoryItem +import ai.kilocode.client.session.history.itemAt +import ai.kilocode.client.session.history.title +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Cursor +import java.awt.FlowLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import javax.swing.DefaultListModel +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.ListSelectionModel + +internal class RecentsList( + sessions: List, + private val controller: SessionController, +) : BorderLayoutPanel() { + private val model = DefaultListModel() + private var hover = -1 + private var snapshot = HistoryActivitySnapshot() + + private val title = JBLabel(KiloBundle.message("session.empty.recent")).apply { + foreground = UIUtil.getContextHelpForeground() + } + + private val list = JBList(model).apply { + isOpaque = false + selectionMode = ListSelectionModel.SINGLE_SELECTION + visibleRowCount = SessionUiStyle.RecentSessions.LIMIT + cellRenderer = Renderer() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + emptyText.clear() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val item = itemAt(this@apply, e) ?: return + controller.openSession(SessionRef.Local(item.session)) + } + + override fun mouseExited(e: MouseEvent) { + hover = -1 + repaint() + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val index = index(e) + if (hover == index) return + hover = index + repaint() + } + }) + } + + init { + isOpaque = false + add(title, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + setSessions(sessions) + } + + fun count() = model.size() + + fun hasSessions() = model.size() > 0 + + fun select(index: Int) { + list.selectedIndex = index + } + + fun selected() = list.selectedIndex + + fun click(index: Int) { + list.selectedIndex = index + controller.openSession(SessionRef.Local(model.getElementAt(index).session)) + } + + fun cursorType() = list.cursor.type + + fun titleFont(font: java.awt.Font) { + title.font = font + } + + fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = + HistoryTime.relative(LocalHistoryItem(session), now) + + fun renderer( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + val old = this.hover + this.hover = if (hover) 0 else -1 + return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also { + this.hover = old + } + } + + @RequiresEdt + fun sync(activity: Map, titles: Map) { + val next = HistoryActivitySnapshot(activity, titles) + val changed = snapshot.changed(next) + snapshot = next + repaintRows(changed) + } + + private fun setSessions(sessions: List) { + model.clear() + sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement) + revalidate() + repaint() + } + + private fun repaintRows(ids: Set) { + if (ids.isEmpty()) return + repeat(model.size()) { index -> + if (model.getElementAt(index).id !in ids) return@repeat + list.getCellBounds(index, index)?.let(list::repaint) + } + } + + private fun index(e: MouseEvent): Int { + val idx = list.locationToIndex(e.point) + if (idx < 0) return -1 + val box = list.getCellBounds(idx, idx) ?: return -1 + if (!box.contains(e.point)) return -1 + return idx + } + + private inner class Renderer : BorderLayoutPanel(), ListCellRenderer { + private val title = JBLabel() + private val badge = JBLabel().apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + private val time = JBLabel() + private val head = BorderLayoutPanel().apply { + add(BorderLayoutPanel().apply { + layout = FlowLayout(FlowLayout.LEFT, 0, 0) + isOpaque = false + add(title) + add(badge) + }, BorderLayout.CENTER) + } + + init { + layout = BorderLayout(UiStyle.Gap.pad(), 0) + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) + head.isOpaque = false + add(head, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } + + override fun getListCellRendererComponent( + list: JList, + value: LocalHistoryItem?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val over = selected || hover == index + isOpaque = over + background = if (over) list.selectionBackground else list.background + title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground() + title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: "" + time.text = value?.let(HistoryTime::relative) ?: "" + setBadge(value?.id?.let(snapshot.activity::get)) + return this + } + + private fun setBadge(kind: SessionActivityKind?) { + badge.isVisible = kind != null + badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) } + } + } +} + +private fun Map.changed(next: Map) = (keys + next.keys).filterTo(mutableSetOf()) { + this[it] != next[it] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 3f9b680cf40..be96983dfbd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -33,6 +33,20 @@ object UiStyle { fun component() = com.intellij.util.ui.JBValue.UIInteger("Component.arc", 8).get() } + /** Platform balloon styling used by lightweight contextual overlays. */ + object Balloon { + fun bg(): Color = UIUtil.getPanelBackground() + + fun border(): Color = JBUI.CurrentTheme.Popup.borderColor(true) + + /** New UI parameter-info balloon insets: symmetric vertical padding with wider sides. */ + fun insets() = JBUI.insets(6, 12, 6, 12) + + fun pointer() = JBUI.size(16, 8) + + fun arc() = JBUI.scale(8) + } + /** Theme-aware colors and color math used by multiple UI surfaces. */ object Colors { fun bg(): Color = UIUtil.getPanelBackground() diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg new file mode 100644 index 00000000000..6b1bcab5732 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg new file mode 100644 index 00000000000..db15066d329 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 96303b3c08a..eeca886cc60 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -11,6 +11,11 @@ session.account.switcher=Switch account session.empty.loading=Loading... session.empty.recent=RECENT session.showHistory=Show History +feedback.button=Feedback & Support +feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing. +feedback.dialog.github=Report an issue on GitHub +feedback.dialog.discord=Join our Discord community +feedback.dialog.support=Customer Support session.scroll.bottom=Scroll to bottom session.scroll.question=Scroll to question session.tab.new=New Session diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 9cb9ee73187..26ff25a94a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code هو مساعد برمجة بالذكاء الا session.empty.loading=جاري التحميل… session.empty.recent=الحديثة session.showHistory=عرض السجل +feedback.button=التغذية الراجعة والدعم +feedback.dialog.message=يسعدنا سماع تعليقاتك أو مساعدتك في حل أي مشكلات تواجهها. +feedback.dialog.github=الإبلاغ عن مشكلة على GitHub +feedback.dialog.discord=الانضمام إلى مجتمع Discord +feedback.dialog.support=دعم العملاء session.scroll.bottom=التمرير إلى الأسفل session.tab.new=جلسة جديدة session.tab.untitled=جلسة بدون عنوان diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index a310da70e8e..df6bad0d4c5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code je AI asistent za kodiranje. Zatražite od njega session.empty.loading=Učitavanje… session.empty.recent=NEDAVNO session.showHistory=Prikaži historiju +feedback.button=Povratne informacije i podrška +feedback.dialog.message=Voljeli bismo čuti vaše povratne informacije ili pomoći s problemima koje doživljavate. +feedback.dialog.github=Prijavite problem na GitHubu +feedback.dialog.discord=Pridružite se našoj Discord zajednici +feedback.dialog.support=Korisnička podrška session.scroll.bottom=Skrolaj na dno session.tab.new=Nova sesija session.tab.untitled=Sesija bez naslova diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 706e2faf0b6..2ad11f62a95 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code er en AI-kodningsassistent. Bed den om at bygge session.empty.loading=Indlæser… session.empty.recent=SENESTE session.showHistory=Vis historik +feedback.button=Feedback & support +feedback.dialog.message=Vi vil gerne høre din feedback eller hjælpe med eventuelle problemer, du oplever. +feedback.dialog.github=Rapportér et problem på GitHub +feedback.dialog.discord=Deltag i vores Discord-fællesskab +feedback.dialog.support=Kundesupport session.scroll.bottom=Rul til bunden session.tab.new=Ny session session.tab.untitled=Unavngivet session diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index f604c18c850..47248c351fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Fun session.empty.loading=Wird geladen… session.empty.recent=ZULETZT session.showHistory=Verlauf anzeigen +feedback.button=Feedback & Support +feedback.dialog.message=Wir würden uns freuen, Ihr Feedback zu hören oder Ihnen bei Problemen zu helfen. +feedback.dialog.github=Ein Problem auf GitHub melden +feedback.dialog.discord=Unserer Discord-Community beitreten +feedback.dialog.support=Kundensupport session.scroll.bottom=Zum Ende scrollen session.tab.new=Neue Sitzung session.tab.untitled=Unbenannte Sitzung diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 92fbad3508f..ce418883385 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code es un asistente de codificación con IA. Pida qu session.empty.loading=Cargando… session.empty.recent=RECIENTE session.showHistory=Mostrar historial +feedback.button=Comentarios y soporte +feedback.dialog.message=Nos encantaría escuchar tus comentarios o ayudarte con cualquier problema que estés experimentando. +feedback.dialog.github=Reportar un problema en GitHub +feedback.dialog.discord=Unirse a nuestra comunidad de Discord +feedback.dialog.support=Atención al cliente session.scroll.bottom=Desplazarse al final session.tab.new=Nueva sesión session.tab.untitled=Sesión sin título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index bf51dd4e426..27bc8f2a241 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code est un assistant de codage IA. Demandez-lui de c session.empty.loading=Chargement… session.empty.recent=RÉCENT session.showHistory=Afficher l'historique +feedback.button=Commentaires & support +feedback.dialog.message=Nous aimerions recueillir vos commentaires ou vous aider avec les problèmes que vous rencontrez. +feedback.dialog.github=Signaler un problème sur GitHub +feedback.dialog.discord=Rejoindre notre communauté Discord +feedback.dialog.support=Service client session.scroll.bottom=Faire défiler vers le bas session.tab.new=Nouvelle session session.tab.untitled=Session sans titre diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index c32cc633e9f..8d750daf83e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo CodeはAIコーディングアシスタントです session.empty.loading=読み込み中… session.empty.recent=最近 session.showHistory=履歴を表示 +feedback.button=フィードバック & サポート +feedback.dialog.message=フィードバックをお聞かせいただくか、問題がある場合はお気軽にご相談ください。 +feedback.dialog.github=GitHubで問題を報告する +feedback.dialog.discord=Discordコミュニティに参加する +feedback.dialog.support=カスタマーサポート session.scroll.bottom=一番下にスクロール session.tab.new=新しいセッション session.tab.untitled=名前なしのセッション diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 893abc95b96..68564e91ac2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code는 AI 코딩 어시스턴트입니다. 기능 session.empty.loading=로딩 중… session.empty.recent=최근 session.showHistory=기록 보기 +feedback.button=피드백 & 지원 +feedback.dialog.message=피드백을 들려주시거나 겪고 계신 문제에 대해 도움을 드리고 싶습니다. +feedback.dialog.github=GitHub에 이슈 보고하기 +feedback.dialog.discord=Discord 커뮤니티 참여하기 +feedback.dialog.support=고객 지원 session.scroll.bottom=맨 아래로 스크롤 session.tab.new=새 세션 session.tab.untitled=제목 없는 세션 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index e88d7960997..e7170dd0463 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code is een AI-codeerassistent. Vraag het om functies session.empty.loading=Laden… session.empty.recent=RECENT session.showHistory=Geschiedenis weergeven +feedback.button=Feedback & Ondersteuning +feedback.dialog.message=We horen graag uw feedback of helpen met eventuele problemen die u ervaart. +feedback.dialog.github=Meld een probleem op GitHub +feedback.dialog.discord=Word lid van onze Discord community +feedback.dialog.support=Klantenservice session.scroll.bottom=Naar beneden scrollen session.tab.new=Nieuwe sessie session.tab.untitled=Naamloze sessie diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index e7631a21d2c..efef3954315 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code er en AI-kodingsassistent. Be den om å bygge fu session.empty.loading=Laster… session.empty.recent=NYLIGE session.showHistory=Vis historikk +feedback.button=Tilbakemelding & støtte +feedback.dialog.message=Vi vil gjerne høre tilbakemeldingene dine eller hjelpe med problemer du opplever. +feedback.dialog.github=Rapporter et problem på GitHub +feedback.dialog.discord=Bli med i Discord-fellesskapet vårt +feedback.dialog.support=Kundestøtte session.scroll.bottom=Rull til bunnen session.tab.new=Ny økt session.tab.untitled=Uten tittel diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 53ddeb68b15..19efd536fe2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code to asystent kodowania AI. Poproś go o tworzenie session.empty.loading=Ładowanie… session.empty.recent=OSTATNIE session.showHistory=Pokaż historię +feedback.button=Opinie i wsparcie +feedback.dialog.message=Chętnie poznamy Twoją opinię lub pomożemy w przypadku problemów. +feedback.dialog.github=Zgłoś problem na GitHubie +feedback.dialog.discord=Dołącz do naszej społeczności Discord +feedback.dialog.support=Wsparcie klienta session.scroll.bottom=Przewiń na dół session.tab.new=Nowa sesja session.tab.untitled=Sesja bez tytułu diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 27c7eb48436..eb91302e646 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code é um assistente de codificação com IA. Peça session.empty.loading=Carregando… session.empty.recent=RECENTE session.showHistory=Mostrar histórico +feedback.button=Feedback e suporte +feedback.dialog.message=Adoraríamos ouvir seu feedback ou ajudar com quaisquer problemas que você esteja enfrentando. +feedback.dialog.github=Reportar um problema no GitHub +feedback.dialog.discord=Entrar na nossa comunidade Discord +feedback.dialog.support=Suporte ao cliente session.scroll.bottom=Rolar para o fim session.tab.new=Nova sessão session.tab.untitled=Sessão sem título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index e6bdb4d1c62..f7f0f4f0a7d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code — это AI-ассистент по прогр session.empty.loading=Загрузка… session.empty.recent=НЕДАВНИЕ session.showHistory=Показать историю +feedback.button=Отзывы и поддержка +feedback.dialog.message=Мы будем рады услышать ваши отзывы или помочь с любыми возникающими проблемами. +feedback.dialog.github=Сообщить о проблеме на GitHub +feedback.dialog.discord=Присоединиться к нашему Discord +feedback.dialog.support=Служба поддержки session.scroll.bottom=Прокрутить вниз session.tab.new=Новая сессия session.tab.untitled=Незаголовок сессия diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 049b1e1d1ab..d886db273ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code คือผู้ช่วยเขียนโ session.empty.loading=กำลังโหลด… session.empty.recent=ล่าสุด session.showHistory=แสดงประวัติ +feedback.button=ข้อเสนอแนะและการสนับสนุน +feedback.dialog.message=เรายินดีรับฟังข้อเสนอแนะของคุณหรือช่วยแก้ไขปัญหาที่คุณพบ +feedback.dialog.github=รายงานปัญหาบน GitHub +feedback.dialog.discord=เข้าร่วมชุมชน Discord ของเรา +feedback.dialog.support=ฝ่ายสนับสนุนลูกค้า session.scroll.bottom=เลื่อนไปด้านล่าง session.tab.new=เซสชันใหม่ session.tab.untitled=เซสชันไม่มีชื่อ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 096fcb1ee94..b10fe527164 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code, bir yapay zeka kodlama asistanıdır. Özellik session.empty.loading=Yükleniyor… session.empty.recent=SON session.showHistory=Geçmişi göster +feedback.button=Geri Bildirim ve Destek +feedback.dialog.message=Geri bildiriminizi almaktan veya yaşadığınız sorunlarda yardımcı olmaktan mutluluk duyarız. +feedback.dialog.github=GitHub'da sorun bildirin +feedback.dialog.discord=Discord topluluğumuza katılın +feedback.dialog.support=Müşteri Desteği session.scroll.bottom=En alta kaýr session.tab.new=Yeni oturum session.tab.untitled=Başlıksız oturum diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 73d770b3c05..6d870a273f2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code — це AI-асистент для програ session.empty.loading=Завантаження… session.empty.recent=НЕДАВНІ session.showHistory=Показати історію +feedback.button=Зворотний зв'язок і підтримка +feedback.dialog.message=Ми раді отримати ваш відгук або допомогти з будь-якими проблемами, які у вас виникли. +feedback.dialog.github=Повідомити про проблему на GitHub +feedback.dialog.discord=Приєднатися до нашої спільноти Discord +feedback.dialog.support=Служба підтримки клієнтів session.scroll.bottom=Прокрутити донизу session.tab.new=Нова сесія session.tab.untitled=Сесія без назви diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 2288060553f..d7f9efc1326 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code 是一个 AI 编程助手。可请它构建功 session.empty.loading=加载中… session.empty.recent=最近 session.showHistory=显示历史 +feedback.button=反馈与支持 +feedback.dialog.message=我们很乐意听取您的反馈,或帮助解决您遇到的任何问题。 +feedback.dialog.github=在 GitHub 上报告问题 +feedback.dialog.discord=加入我们的 Discord 社区 +feedback.dialog.support=客户支持 session.scroll.bottom=滚动到底部 session.tab.new=新建会话 session.tab.untitled=无标题会话 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 40fb78179b3..ae919bc03d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code 是 AI 程式輔助。可請它建置功能、 session.empty.loading=載入中… session.empty.recent=最近 session.showHistory=顯示歷史 +feedback.button=意見回饋與支援 +feedback.dialog.message=我們很樂意聆聽您的意見回饋,或協助解決您遇到的任何問題。 +feedback.dialog.github=在 GitHub 上回報問題 +feedback.dialog.discord=加入我們的 Discord 社群 +feedback.dialog.support=客戶支援 session.scroll.bottom=滾動到底部 session.tab.new=新建工作階段 session.tab.untitled=未命名的工作階段 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 52fb937892c..30160c71c5f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -72,7 +72,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val rpc = session("ses_1") val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) - val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) + val panel = ai.kilocode.client.session.ui.empty.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) panel.clickRecent(0) @@ -84,7 +84,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val manager = FakeManager() val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) - val panel = ai.kilocode.client.session.ui.EmptySessionPanel( + val panel = ai.kilocode.client.session.ui.empty.EmptySessionPanel( testRootDisposable, controller, emptyList(), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 59f5aeb3cd9..fc91e303e1f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -8,7 +8,7 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ConnectionPanel -import ai.kilocode.client.session.ui.EmptySessionPanel +import ai.kilocode.client.session.ui.empty.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.account.SessionAccountOverlay diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 521e678a67d..1514d0d842a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -6,11 +6,11 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind -import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.ui.empty.EmptySessionPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi @@ -33,6 +33,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.awt.BorderLayout import java.awt.Cursor +import javax.swing.JButton @Suppress("UnstableApiUsage") class EmptySessionPanelTest : BasePlatformTestCase() { @@ -82,10 +83,10 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertFalse(panel.loadingVisible()) } - fun `test recent section remains visible when empty`() { + fun `test recent section is hidden when empty`() { val panel = panel() - assertTrue(panel.recentVisible()) + assertFalse(panel.recentVisible()) assertEquals(0, panel.recentCount()) } @@ -160,11 +161,20 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(ai.kilocode.client.plugin.KiloBundle.message("session.showHistory"), panel.showHistoryText()) } + fun `test feedback button uses localized text and icon`() { + val panel = panel() + + assertEquals(KiloBundle.message("feedback.button"), panel.feedbackText()) + assertNotNull(panel.feedbackIcon()) + } + fun `test action controls use hand cursor and no show history outline`() { val panel = panel() assertFalse(panel.showHistoryBorderPainted()) + assertFalse(panel.feedbackBorderPainted()) assertEquals(Cursor.HAND_CURSOR, panel.showHistoryCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.feedbackCursor()) assertEquals(Cursor.HAND_CURSOR, panel.recentCursor()) } @@ -177,6 +187,36 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(1, calls) } + fun `test feedback popup content opens expected destinations`() { + val panel = panel() + val opened = mutableListOf() + val content = panel.feedbackContent { opened.add(it) } + val buttons = UIUtil.uiTraverser(content).filter(JButton::class.java).toList() + + assertEquals( + listOf( + KiloBundle.message("feedback.dialog.github"), + KiloBundle.message("feedback.dialog.discord"), + KiloBundle.message("feedback.dialog.support"), + ), + buttons.map { it.text }, + ) + + buttons.forEach { it.doClick() } + + assertEquals(panel.feedbackUrls(), opened) + } + + fun `test feedback discord action has icon`() { + val panel = panel() + val content = panel.feedbackContent() + val discord = UIUtil.uiTraverser(content) + .filter(JButton::class.java) + .first { it.text == KiloBundle.message("feedback.dialog.discord") } + + assertNotNull(discord.icon) + } + fun `test renderer aligns title center and time east`() { val cell = panel().rendererComponent(session("ses_1")) as BorderLayoutPanel val layout = cell.layout as BorderLayout From 253f8351ec62c1c611e251de56d7c7f18a7f8579 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 15:32:13 -0400 Subject: [PATCH 048/201] feat(kilo-jetbrains): add models settings --- .../backend/app/KiloBackendAppService.kt | 24 ++ .../kilocode/backend/cli/KiloCliDataParser.kt | 26 ++ .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 9 + .../backend/rpc/KiloWorkspaceDtoMapper.kt | 93 +++++ .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 169 ++++---- .../backend/cli/KiloCliDataParserTest.kt | 43 ++ .../client/actions/OpenSettingsAction.kt | 32 ++ .../ai/kilocode/client/app/KiloAppService.kt | 10 + .../client/app/KiloWorkspaceService.kt | 11 + .../client/session/ui/model/ModelPicker.kt | 17 +- .../settings/KiloSettingsConfigurable.kt | 9 + .../settings/models/ModelSettingPicker.kt | 40 ++ .../settings/models/ModelsConfigurable.kt | 65 ++++ .../settings/models/ModelsSettingsState.kt | 77 ++++ .../settings/models/ModelsSettingsUi.kt | 367 ++++++++++++++++++ .../client/settings/ui/SettingsRow.kt | 55 +++ .../resources/kilo.jetbrains.frontend.xml | 20 +- .../resources/messages/KiloBundle.properties | 24 ++ .../session/ui/model/ModelPickerTest.kt | 17 + .../settings/KiloSettingsConfigurableTest.kt | 23 ++ .../models/ModelsSettingsStateTest.kt | 112 ++++++ .../kilocode/client/testing/FakeAppRpcApi.kt | 29 ++ .../client/testing/FakeWorkspaceRpcApi.kt | 7 + .../kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt | 4 + .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 4 + .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 14 + .../kilocode/rpc/dto/KiloWorkspaceStateDto.kt | 7 + 27 files changed, 1209 insertions(+), 99 deletions(-) create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 240d562cdc9..9846109f157 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.cli.KiloBackendCliManager +import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService import ai.kilocode.backend.migration.LegacyMigrationDetection import ai.kilocode.backend.telemetry.KiloBackendTelemetry @@ -19,6 +20,7 @@ import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest import ai.kilocode.jetbrains.api.model.ProviderOauthCallbackRequest import ai.kilocode.rpc.dto.DeviceAuthDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.HealthDto import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service @@ -211,6 +213,28 @@ class KiloBackendAppService private constructor( } } + suspend fun updateConfig(patch: ConfigPatchDto): KiloAppState { + val http = connection.apiClient ?: throw IllegalStateException("Not connected") + val current = _appState.value as? KiloAppState.Ready ?: throw IllegalStateException("Kilo backend is not ready") + val body = KiloCliDataParser.buildConfigPatch(patch) + val request = Request.Builder() + .url("http://127.0.0.1:$port/global/config") + .header("Accept", "application/json") + .patch(body.toRequestBody("application/json".toMediaType())) + .build() + withContext(Dispatchers.IO) { + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val text = response.body?.string() + log.warn("Global config patch failed: HTTP ${response.code} ${response.message} ${text.orEmpty()}") + throw IllegalStateException("Global config patch failed: HTTP ${response.code} ${response.message}") + } + } + } + refreshConfigState() + return (_appState.value as? KiloAppState.Ready) ?: current + } + internal suspend fun resumeAfterMigration() { mutex.withLock { if (_appState.value !is KiloAppState.MigrationRequired) return diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index a31df2b715e..ec5980b5e5b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -8,6 +8,7 @@ import ai.kilocode.backend.workspace.ProviderInfo import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.CloudSessionListDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto @@ -429,6 +430,31 @@ object KiloCliDataParser { return sb.toString() } + fun buildConfigPatch(patch: ConfigPatchDto): String { + val allowed = setOf("model", "small_model", "subagent_model", "subagent_variant") + val sb = StringBuilder("{") + var first = true + fun sep() { if (!first) sb.append(","); first = false } + fun value(value: String?) = value?.let(::escape) ?: "null" + + for ((key, value) in patch.values) { + if (key !in allowed) continue + sep(); sb.append("\"$key\":${value(value)}") + } + + if (patch.agents.isNotEmpty()) { + sep(); sb.append("\"agent\":{") + patch.agents.entries.forEachIndexed { idx, (name, agent) -> + if (idx > 0) sb.append(",") + sb.append("${escape(name)}:{\"model\":${value(agent.model)}}") + } + sb.append("}") + } + + sb.append("}") + return sb.toString() + } + // ================================================================ // Internal — message/part/session parsing // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 668db20b290..27b6ce1f4a8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -15,6 +15,7 @@ import ai.kilocode.jetbrains.api.model.ConfigAgent import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.AgentConfigDto import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.KiloAppRpcApi import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.DeviceAuthDto @@ -85,6 +86,11 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { return app.models.variant(update) } + override suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto { + app.requireReady() + return appStateDto(app.updateConfig(patch)) + } + override suspend fun refreshProfile(): ProfileDto? = app.refreshProfile()?.let(::profileDto) override suspend fun startLogin(directory: String?): DeviceAuthDto = app.startLogin(directory) @@ -169,6 +175,9 @@ private fun warning(w: ConfigWarning) = ConfigWarningDto( private fun config(c: Config) = ConfigDto( model = c.model, + smallModel = c.smallModel, + subagentModel = c.subagentModel, + subagentVariant = c.subagentVariant, agent = agents(c.agent), ) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt new file mode 100644 index 00000000000..fc6c4246b99 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt @@ -0,0 +1,93 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.LoadError +import ai.kilocode.backend.workspace.AgentData +import ai.kilocode.backend.workspace.AgentInfo +import ai.kilocode.backend.workspace.CommandInfo +import ai.kilocode.backend.workspace.KiloWorkspaceLoadProgress +import ai.kilocode.backend.workspace.ModelInfo +import ai.kilocode.backend.workspace.ProviderData +import ai.kilocode.backend.workspace.ProviderInfo +import ai.kilocode.backend.workspace.SkillInfo +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.AgentsDto +import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ModelLimitDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.ProvidersDto +import ai.kilocode.rpc.dto.SkillDto + +internal object KiloWorkspaceDtoMapper { + fun error(e: LoadError) = LoadErrorDto( + resource = e.resource, + status = e.status, + detail = e.detail, + ) + + fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( + providers = p.providers, + agents = p.agents, + commands = p.commands, + skills = p.skills, + ) + + fun providers(d: ProviderData) = ProvidersDto( + providers = d.providers.map(::provider), + connected = d.connected, + defaults = d.defaults, + ) + + fun agents(d: AgentData) = AgentsDto( + agents = d.agents.map(::agent), + all = d.all.map(::agent), + default = d.default, + ) + + fun command(c: CommandInfo) = CommandDto( + name = c.name, + description = c.description, + source = c.source, + hints = c.hints, + ) + + fun skill(s: SkillInfo) = SkillDto( + name = s.name, + description = s.description, + location = s.location, + ) + + private fun provider(p: ProviderInfo) = ProviderDto( + id = p.id, + name = p.name, + source = p.source, + models = p.models.mapValues { (_, m) -> model(m) }, + ) + + private fun model(m: ModelInfo) = ModelDto( + id = m.id, + name = m.name, + attachment = m.attachment, + reasoning = m.reasoning, + temperature = m.temperature, + toolCall = m.toolCall, + free = m.free, + status = m.status, + recommendedIndex = m.recommendedIndex, + variants = m.variants, + limit = m.limit?.let { ModelLimitDto(it.context, it.input, it.output) }, + ) + + private fun agent(a: AgentInfo) = AgentDto( + name = a.name, + displayName = a.displayName, + description = a.description, + mode = a.mode, + native = a.native, + hidden = a.hidden, + color = a.color, + deprecated = a.deprecated, + ) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 82fe6605966..2cfefb8c997 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -5,48 +5,39 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.app.LoadError +import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo -import ai.kilocode.backend.workspace.CommandInfo import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager -import ai.kilocode.backend.workspace.KiloWorkspaceLoadProgress import ai.kilocode.backend.workspace.KiloWorkspaceState -import ai.kilocode.backend.workspace.ModelInfo -import ai.kilocode.backend.workspace.ProviderData -import ai.kilocode.backend.workspace.ProviderInfo -import ai.kilocode.backend.workspace.SkillInfo import ai.kilocode.log.KiloLog +import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.KiloWorkspaceRpcApi -import ai.kilocode.rpc.dto.AgentDto -import ai.kilocode.rpc.dto.AgentsDto -import ai.kilocode.rpc.dto.CommandDto -import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.LoadErrorDto -import ai.kilocode.rpc.dto.ModelDto -import ai.kilocode.rpc.dto.ModelLimitDto -import ai.kilocode.rpc.dto.ProviderDto -import ai.kilocode.rpc.dto.ProvidersDto -import ai.kilocode.rpc.dto.SkillDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.service import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project -import com.intellij.openapi.components.service import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.LocalFileSystem +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Request import java.net.URI import java.net.URLDecoder +import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.nio.file.InvalidPathException import java.nio.file.Path @@ -101,6 +92,53 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { manager.get(directory).reload() } + override suspend fun models(directory: String): ModelsWorkspaceDto { + app.requireReady() + val api = app.api ?: throw IllegalStateException("Kilo API is unavailable") + val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") + val errors = mutableListOf() + + val prov = try { + val raw = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("http://127.0.0.1:${app.port}/provider?directory=${encode(directory)}") + .get() + .build() + http.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) throw RuntimeException("HTTP ${response.code}: $body") + body + } + } + KiloCliDataParser.parseProviders(raw) + } catch (e: Exception) { + LOG.warn("Models settings providers fetch failed for $directory: ${e.message}", e) + errors.add(LoadError(resource = "providers", detail = e.message)) + null + } + + val agents = try { + val response = api.appAgents(directory = directory) + val mapped = response.map(::agent) + val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } + AgentData( + agents = visible.map(::agent), + all = mapped, + default = visible.firstOrNull()?.name ?: "code", + ) + } catch (e: Exception) { + LOG.warn("Models settings agents fetch failed for $directory: ${e.message}", e) + errors.add(LoadError(resource = "agents", detail = e.message)) + null + } + + return ModelsWorkspaceDto( + providers = prov?.let(KiloWorkspaceDtoMapper::providers), + agents = agents?.let(KiloWorkspaceDtoMapper::agents), + errors = errors.map(KiloWorkspaceDtoMapper::error), + ) + } + override suspend fun files(directory: String, path: String): List { val item = clean(path) ?: return emptyList() val file = file(item) ?: return emptyList() @@ -167,6 +205,17 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { } ?: projects.firstOrNull() } + private fun agent(a: Agent) = AgentInfo( + name = a.name, + displayName = a.displayName, + description = a.description, + mode = a.mode.value, + native = a.native, + hidden = a.hidden, + color = a.color, + deprecated = a.deprecated, + ) + // ------ mapping: domain model → DTO ------ private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto = @@ -174,89 +223,21 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { KiloWorkspaceState.Pending -> KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) is KiloWorkspaceState.Loading -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.LOADING, - progress = progress(state.progress), + progress = KiloWorkspaceDtoMapper.progress(state.progress), ) is KiloWorkspaceState.Ready -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.READY, - providers = providers(state.providers), - agents = agents(state.agents), - commands = state.commands.map(::command), - skills = state.skills.map(::skill), + providers = KiloWorkspaceDtoMapper.providers(state.providers), + agents = KiloWorkspaceDtoMapper.agents(state.agents), + commands = state.commands.map(KiloWorkspaceDtoMapper::command), + skills = state.skills.map(KiloWorkspaceDtoMapper::skill), ) is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, error = state.message, - errors = state.errors.map(::error), + errors = state.errors.map(KiloWorkspaceDtoMapper::error), ) } - - private fun error(e: LoadError) = LoadErrorDto( - resource = e.resource, - status = e.status, - detail = e.detail, - ) - - private fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( - providers = p.providers, - agents = p.agents, - commands = p.commands, - skills = p.skills, - ) - - private fun providers(d: ProviderData) = ProvidersDto( - providers = d.providers.map(::provider), - connected = d.connected, - defaults = d.defaults, - ) - - private fun provider(p: ProviderInfo) = ProviderDto( - id = p.id, - name = p.name, - source = p.source, - models = p.models.mapValues { (_, m) -> model(m) }, - ) - - private fun model(m: ModelInfo) = ModelDto( - id = m.id, - name = m.name, - attachment = m.attachment, - reasoning = m.reasoning, - temperature = m.temperature, - toolCall = m.toolCall, - free = m.free, - status = m.status, - recommendedIndex = m.recommendedIndex, - variants = m.variants, - limit = m.limit?.let { ModelLimitDto(it.context, it.input, it.output) }, - ) - - private fun agents(d: AgentData) = AgentsDto( - agents = d.agents.map(::agent), - all = d.all.map(::agent), - default = d.default, - ) - - private fun agent(a: AgentInfo) = AgentDto( - name = a.name, - displayName = a.displayName, - description = a.description, - mode = a.mode, - native = a.native, - hidden = a.hidden, - color = a.color, - deprecated = a.deprecated, - ) - - private fun command(c: CommandInfo) = CommandDto( - name = c.name, - description = c.description, - source = c.source, - hints = c.hints, - ) - - private fun skill(s: SkillInfo) = SkillDto( - name = s.name, - description = s.description, - location = s.location, - ) } + +private fun encode(value: String) = URLEncoder.encode(value, Charsets.UTF_8) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index b73e923eef2..979e7738d3c 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -3,6 +3,8 @@ package ai.kilocode.backend.cli import ai.kilocode.backend.workspace.CommandInfo import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto @@ -1399,6 +1401,47 @@ class KiloCliDataParserTest { assertEquals("{}", result) } + @Test + fun `buildConfigPatch - top-level model set`() { + val patch = ConfigPatchDto(values = linkedMapOf("model" to "anthropic/claude")) + assertEquals("{\"model\":\"anthropic/claude\"}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - top-level model clear emits null`() { + val patch = ConfigPatchDto(values = linkedMapOf("model" to null)) + assertEquals("{\"model\":null}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - small and subagent values`() { + val patch = ConfigPatchDto(values = linkedMapOf("small_model" to "kilo/auto-small", "subagent_model" to null, "subagent_variant" to null)) + assertEquals("{\"small_model\":\"kilo/auto-small\",\"subagent_model\":null,\"subagent_variant\":null}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - per-agent model set`() { + val patch = ConfigPatchDto(agents = linkedMapOf("code" to AgentConfigPatchDto(model = "kilo/gpt-5"))) + assertEquals("{\"agent\":{\"code\":{\"model\":\"kilo/gpt-5\"}}}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - per-agent model clear emits null`() { + val patch = ConfigPatchDto(agents = linkedMapOf("code" to AgentConfigPatchDto(model = null))) + assertEquals("{\"agent\":{\"code\":{\"model\":null}}}", KiloCliDataParser.buildConfigPatch(patch)) + } + + @Test + fun `buildConfigPatch - empty patch`() { + assertEquals("{}", KiloCliDataParser.buildConfigPatch(ConfigPatchDto())) + } + + @Test + fun `buildConfigPatch - escapes special characters`() { + val patch = ConfigPatchDto(values = linkedMapOf("model" to "kilo/a\\b\"c")) + assertEquals("{\"model\":\"kilo/a\\\\b\\\"c\"}", KiloCliDataParser.buildConfigPatch(patch)) + } + @Test fun `buildConfigPartial - temperature without agent defaults to ask`() { val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt new file mode 100644 index 00000000000..c5e28f29d8a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt @@ -0,0 +1,32 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.KiloSettingsConfigurable +import ai.kilocode.client.telemetry.Telemetry +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableWithId +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAwareAction +import java.util.function.Predicate + +class OpenSettingsAction : DumbAwareAction( + KiloBundle.message("action.Kilo.OpenSettings.text"), + KiloBundle.message("action.Kilo.OpenSettings.description"), + AllIcons.General.GearPlain, +) { + override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("Settings Opened", mapOf("surface" to "tool_window")) + ShowSettingsUtil.getInstance().showSettingsDialog( + e.project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == KiloSettingsConfigurable.ID + }, + null, + ) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 4918660bc25..6d423d05cff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto @@ -214,6 +215,15 @@ class KiloAppService internal constructor( } } + suspend fun updateConfig(patch: ConfigPatchDto): Boolean = try { + val next = call { updateConfig(patch) } + _state.value = next + true + } catch (e: Exception) { + LOG.warn("config update failed", e) + false + } + private fun setModelState(state: ModelStateDto) { _models.value = state _favorites.value = state.favorite diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index ab818b48eaa..99fc7aef8b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -5,6 +5,8 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.openapi.components.Service import ai.kilocode.log.KiloLog @@ -100,6 +102,15 @@ class KiloWorkspaceService internal constructor( } } + suspend fun models(directory: String): ModelsWorkspaceDto { + return try { + call { this.models(directory) } + } catch (e: Exception) { + LOG.warn("models settings lookup failed for directory=$directory", e) + ModelsWorkspaceDto(errors = listOf(LoadErrorDto(resource = "models", detail = e.message))) + } + } + suspend fun files(directory: String, path: String): List { return try { call { files(directory, path) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index 6913142e533..97826e8a7b2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -64,6 +64,8 @@ class ModelPicker : PickerButton() { var onSelect: (Item) -> Unit = {} var favorites: () -> List = { emptyList() } var onFavoriteToggle: (Item) -> Unit = {} + var allowEmpty: Boolean = false + var emptyText: String = KiloBundle.message("settings.models.notSet") private var items: List = emptyList() private var selected: Item? = null @@ -85,7 +87,7 @@ class ModelPicker : PickerButton() { items = values val key = default ?: selected?.key selected = key?.let { target -> values.firstOrNull { it.key == target || it.id == target } } - ?: values.firstOrNull() + ?: if (allowEmpty) null else values.firstOrNull() refresh() } @@ -96,15 +98,22 @@ class ModelPicker : PickerButton() { internal fun selectedForTest(): Item? = selected + fun clearSelection() { + selected = null + refresh() + } + + fun selectionKeyForTest(): String? = selected?.key + private fun refresh() { if (items.isEmpty()) { isEnabled = false - text = " " + text = if (allowEmpty) emptyText else " " cursor = Cursor.getDefaultCursor() return } - val display = selected?.display ?: items.firstOrNull()?.display ?: "" - text = "${ModelText.sanitize(display)} ▴" + val display = selected?.display + text = if (display == null && allowEmpty) "$emptyText ▴" else "${ModelText.sanitize(display ?: items.first().display)} ▴" isEnabled = true cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt index e3775550f41..b01aaeb666d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.ui.layout.Stack import com.intellij.ide.DataManager @@ -45,6 +46,14 @@ class KiloSettingsConfigurable : SearchableConfigurable { link.border = JBUI.Borders.emptyBottom(4) panel.next(link) + val models = ActionLink(KiloBundle.message("settings.models.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, ModelsConfigurable.ID) + } + models.border = JBUI.Borders.emptyBottom(4) + panel.next(models) + return panel } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt new file mode 100644 index 00000000000..21922c7f321 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt @@ -0,0 +1,40 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.ui.components.ActionLink + +internal class ModelSettingPicker( + private val clear: () -> Unit, +) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()) { + val picker = ModelPicker() + private val reset = ActionLink(KiloBundle.message("settings.models.reset")) { clear() } + private var active = true + + init { + picker.allowEmpty = true + picker.emptyText = KiloBundle.message("settings.models.notSet") + next(picker) + next(reset) + reset.isVisible = false + } + + fun setItems(items: List, selected: String?) { + picker.setItems(items, selected) + if (!active) picker.isEnabled = false + } + + fun setClearVisible(value: Boolean) { + reset.isVisible = value + } + + override fun setEnabled(value: Boolean) { + active = value + super.setEnabled(value) + if (!value) picker.isEnabled = false + reset.isEnabled = value + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt new file mode 100644 index 00000000000..6045f58c3f5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt @@ -0,0 +1,65 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.options.SearchableConfigurable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import javax.swing.JComponent + +class ModelsConfigurable : SearchableConfigurable { + private var ui: ModelsSettingsUi? = null + private var scope: CoroutineScope? = null + + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.models.displayName") + + override fun createComponent(): JComponent { + val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default) + scope = cs + val dir = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault }?.basePath + val panel = ModelsSettingsUi(cs, directory = dir) + ui = panel + return panel + } + + override fun isModified(): Boolean = ui?.modified() == true + + override fun apply() { + ui?.applyDraft() + } + + override fun reset() { + ui?.resetDraft() + } + + override fun disposeUIResources() { + val panel = ui + val cs = scope + ui = null + scope = null + val app = ApplicationManager.getApplication() + if (panel != null && app.isDispatchThread) { + panel.dispose() + cs?.cancel() + return + } + if (panel != null) { + app.invokeLater({ + panel.dispose() + cs?.cancel() + }, ModalityState.any()) + return + } + cs?.cancel() + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.models" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt new file mode 100644 index 00000000000..b7fa01d4027 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt @@ -0,0 +1,77 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ProvidersDto + +internal data class ModelsDraft( + val model: String? = null, + val small: String? = null, + val subagent: String? = null, + val variant: String? = null, + val agents: Map = emptyMap(), +) + +internal fun modelsDraft(config: ConfigDto?, agents: List): ModelsDraft = ModelsDraft( + model = config?.model, + small = config?.smallModel, + subagent = config?.subagentModel, + variant = config?.subagentVariant, + agents = agents.associate { item -> item.name to config?.agent?.get(item.name)?.model }, +) + +internal fun patch(from: ModelsDraft, to: ModelsDraft): ConfigPatchDto { + val values = linkedMapOf() + if (from.model != to.model) values["model"] = to.model + if (from.small != to.small) values["small_model"] = to.small + if (from.subagent != to.subagent) { + values["subagent_model"] = to.subagent + if (to.subagent == null || from.variant != to.variant) values["subagent_variant"] = to.variant + } else if (from.variant != to.variant) { + values["subagent_variant"] = to.variant + } + + val agents = linkedMapOf() + for (name in (from.agents.keys + to.agents.keys).sorted()) { + if (from.agents[name] != to.agents[name]) agents[name] = AgentConfigPatchDto(model = to.agents[name]) + } + return ConfigPatchDto(values = values, agents = agents) +} + +internal fun parseSelection(key: String?): Pair? { + val idx = key?.indexOf('/') ?: return null + if (idx <= 0 || idx >= key.length - 1) return null + return key.substring(0, idx) to key.substring(idx + 1) +} + +internal fun key(provider: String, model: String): String = "$provider/$model" + +internal enum class ModelsStatus { + UNAVAILABLE, + LOADING, + LOAD_FAILED, + NO_PROVIDERS, + MODES_FAILED, + READY, + SAVING, +} + +internal fun modelsStatus( + ready: Boolean, + loading: Boolean, + providers: ProvidersDto?, + items: Int, + errors: List, + saving: Boolean, +): ModelsStatus { + if (saving) return ModelsStatus.SAVING + if (!ready) return ModelsStatus.UNAVAILABLE + if (loading) return ModelsStatus.LOADING + if (providers == null) return if (errors.isNotEmpty()) ModelsStatus.LOAD_FAILED else ModelsStatus.LOADING + if (items == 0) return ModelsStatus.NO_PROVIDERS + if (errors.any { it.resource == "agents" }) return ModelsStatus.MODES_FAILED + return ModelsStatus.READY +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt new file mode 100644 index 00000000000..e52c8f03791 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -0,0 +1,367 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.ReasoningPicker +import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.session.ui.model.ModelText +import ai.kilocode.client.settings.profile.edt +import ai.kilocode.client.settings.ui.SettingsRow +import ai.kilocode.client.settings.ui.SettingsRows +import ai.kilocode.client.ui.UiStyle +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.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.dto.ProvidersDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.ui.TitledSeparator +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import javax.swing.JPanel + +internal class ModelsSettingsUi( + private val cs: CoroutineScope, + private val app: KiloAppService = service(), + private val workspaces: KiloWorkspaceService = service(), + private val directory: String? = null, +) : JPanel(BorderLayout()) { + + private val status = JBLabel(KiloBundle.message("settings.models.loading")) + private val content = Stack.vertical() + private val rows = SettingsRows() + private val modes = SettingsRows() + private val defaults = ModelSettingPicker { update { copy(model = null) } } + private val small = ModelSettingPicker { update { copy(small = null) } } + private val subagent = ModelSettingPicker { update { copy(subagent = null, variant = null) } } + private val variant = ReasoningPicker() + private val pickers = mutableMapOf() + private val jobs = mutableListOf() + + private var baseline = ModelsDraft() + private var draft = ModelsDraft() + private var providers: ProvidersDto? = null + private var agents: List = emptyList() + private var appState: KiloAppStateDto = app.state.value + private var dir: String? = null + private var loading = false + private var loaded = false + private var errors: List = emptyList() + private var allItems: List = emptyList() + private var saving = false + + init { + status.foreground = UIUtil.getContextHelpForeground() + defaults.picker.onSelect = { update { copy(model = it.key) } } + small.picker.onSelect = { update { copy(small = it.key) } } + subagent.picker.onSelect = { item -> selectSubagent(item) } + variant.onSelect = { item -> update { copy(variant = item.id) } } + listOf(defaults, small, subagent).forEach { picker -> + picker.picker.favorites = { app.favorites.value } + picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } + } + + content.next(status) + content.gap(UiStyle.Gap.lg()) + content.next(TitledSeparator(KiloBundle.message("settings.models.displayName"))) + rows.row(SettingsRow( + KiloBundle.message("settings.models.defaultModel.title"), + KiloBundle.message("settings.models.defaultModel.description"), + defaults, + )) + rows.row(SettingsRow( + KiloBundle.message("settings.models.smallModel.title"), + KiloBundle.message("settings.models.smallModel.description"), + small, + )) + rows.row(SettingsRow( + KiloBundle.message("settings.models.subagentModel.title"), + KiloBundle.message("settings.models.subagentModel.description"), + subagent, + )) + rows.row(SettingsRow( + KiloBundle.message("settings.models.subagentVariant.title"), + KiloBundle.message("settings.models.subagentVariant.description"), + variant.align(HAlign.RIGHT, VAlign.CENTER), + )) + content.next(rows) + content.next(TitledSeparator(KiloBundle.message("settings.models.modeModels.title"))) + content.next(JBLabel(KiloBundle.message("settings.models.modeModels.description")).apply { + foreground = UIUtil.getContextHelpForeground() + font = UiStyle.Fonts.small() + }) + content.next(modes) + add(JBScrollPane(content).apply { border = null }, BorderLayout.CENTER) + sync() + start() + } + + @RequiresEdt + fun modified(): Boolean { + checkEdt() + return draft != baseline + } + + @RequiresEdt + fun resetDraft() { + checkEdt() + draft = baseline + if (!saving) status.text = "" + sync() + } + + @RequiresEdt + fun applyDraft() { + checkEdt() + val prev = baseline + val next = draft + val patch = patch(prev, next) + if (patch.values.isEmpty() && patch.agents.isEmpty()) return + baseline = next + saving = true + status.text = KiloBundle.message("settings.models.save.pending") + status.foreground = UIUtil.getContextHelpForeground() + sync() + cs.launch { + val ok = app.updateConfig(patch) + withContext(edt) { + if (ok) { + saving = false + status.text = "" + sync() + return@withContext + } + baseline = prev + saving = false + status.text = KiloBundle.message("settings.models.save.failed") + status.foreground = UiStyle.Colors.errorLabelForeground() + sync() + } + } + } + + @RequiresEdt + fun updateApp(state: KiloAppStateDto) { + checkEdt() + appState = state + if (state.status != KiloAppStatusDto.READY) { + loading = false + loaded = false + providers = null + agents = emptyList() + errors = emptyList() + } + val base = modelsDraft(state.config, agents) + baseline = base + if (!saving) draft = base + sync() + loadModels() + } + + @RequiresEdt + fun updateModelsWorkspace(state: ModelsWorkspaceDto) { + checkEdt() + providers = state.providers + agents = state.agents?.agents ?: emptyList() + errors = state.errors + loaded = true + loading = false + val base = modelsDraft(appState.config, agents) + baseline = base + if (!saving) draft = base + sync() + } + + @RequiresEdt + fun updateModels(state: ai.kilocode.rpc.dto.ModelStateDto) { + checkEdt() + sync() + } + + @RequiresEdt + fun dispose() { + checkEdt() + jobs.forEach { it.cancel() } + jobs.clear() + cs.cancel() + } + + private fun start() { + jobs += cs.launch { + app.state.collect { state -> withContext(edt) { updateApp(state) } } + } + jobs += cs.launch { + app.models.collect { state -> withContext(edt) { updateModels(state) } } + } + jobs += cs.launch { app.connect() } + val hint = directory ?: return + jobs += cs.launch { + val resolved = workspaces.resolveProjectDirectory(hint) + withContext(edt) { + dir = resolved + loaded = false + loadModels() + sync() + } + } + } + + @RequiresEdt + private fun loadModels() { + checkEdt() + val root = dir ?: return + if (appState.status != KiloAppStatusDto.READY || loading || loaded) return + loading = true + errors = emptyList() + sync() + jobs += cs.launch { + val state = workspaces.models(root) + withContext(edt) { updateModelsWorkspace(state) } + } + } + + @RequiresEdt + private fun sync() { + checkEdt() + allItems = items(false) + val smallItems = items(true) + val state = modelsStatus( + ready = appState.status == KiloAppStatusDto.READY, + loading = loading || (appState.status == KiloAppStatusDto.READY && !loaded && dir != null), + providers = providers, + items = allItems.size, + errors = errors, + saving = saving, + ) + val ready = state == ModelsStatus.READY || state == ModelsStatus.MODES_FAILED + if (!saving) { + when (state) { + ModelsStatus.UNAVAILABLE -> { + status.text = KiloBundle.message("settings.models.unavailable") + status.foreground = UIUtil.getContextHelpForeground() + } + ModelsStatus.LOADING -> { + status.text = KiloBundle.message("settings.models.loading") + status.foreground = UIUtil.getContextHelpForeground() + } + ModelsStatus.LOAD_FAILED -> { + status.text = KiloBundle.message("settings.models.load.failed") + status.foreground = UiStyle.Colors.errorLabelForeground() + } + ModelsStatus.NO_PROVIDERS -> { + status.text = KiloBundle.message("settings.models.noProviders") + status.foreground = UIUtil.getContextHelpForeground() + } + ModelsStatus.MODES_FAILED -> { + status.text = KiloBundle.message("settings.models.modes.failed") + status.foreground = UiStyle.Colors.warningLabelForeground() + } + ModelsStatus.READY, + ModelsStatus.SAVING -> { + status.text = "" + status.foreground = UIUtil.getContextHelpForeground() + } + } + } + status.isVisible = status.text.isNotBlank() + defaults.setItems(allItems, draft.model) + small.setItems(smallItems, draft.small) + subagent.setItems(allItems, draft.subagent) + defaults.setClearVisible(draft.model != null) + small.setClearVisible(draft.small != null) + subagent.setClearVisible(draft.subagent != null) + listOf(defaults, small, subagent).forEach { it.isEnabled = ready } + syncVariant(ready) + syncModes(ready) + revalidate() + repaint() + } + + private fun items(includeSmall: Boolean): List { + val cfg = providers ?: return emptyList() + return cfg.providers + .filter { it.id == KILO_PROVIDER || it.id in cfg.connected } + .flatMap { provider -> + provider.models.mapNotNull { (id, model) -> + val item = ModelPicker.Item(id, model.name, provider.id, provider.name, model.recommendedIndex, model.free, model.variants) + if (!includeSmall && ModelText.small(item)) return@mapNotNull null + item + } + } + } + + @RequiresEdt + private fun syncVariant(ready: Boolean) { + val item = allItems.firstOrNull { it.key == draft.subagent || it.id == draft.subagent } + val valid = item?.variants.orEmpty() + if (draft.variant != null && draft.variant !in valid) draft = draft.copy(variant = valid.firstOrNull()) + if (draft.subagent != null && valid.isEmpty() && draft.variant != null) draft = draft.copy(variant = null) + variant.setItems(valid.map { ReasoningPicker.Item(it, variantTitle(it)) }, draft.variant) + variant.isEnabled = ready && valid.isNotEmpty() + variant.isVisible = valid.isNotEmpty() + } + + @RequiresEdt + private fun syncModes(ready: Boolean) { + val names = agents.map { it.name }.toSet() + if (names != pickers.keys) { + modes.removeAll() + pickers.clear() + agents.forEach { agent -> + val picker = ModelSettingPicker { update { copy(agents = this.agents + (agent.name to null)) } } + picker.picker.favorites = { app.favorites.value } + picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } + picker.picker.onSelect = { item -> update { copy(agents = this.agents + (agent.name to item.key)) } } + pickers[agent.name] = picker + modes.row(SettingsRow( + agent.displayName ?: title(agent.name), + agent.description, + picker, + )) + } + } + for ((name, picker) in pickers) { + val value = draft.agents[name] + picker.setItems(allItems, value) + picker.setClearVisible(value != null) + picker.isEnabled = ready + } + } + + private fun selectSubagent(item: ModelPicker.Item) { + val variant = if (draft.subagent == item.key && draft.variant in item.variants) draft.variant else item.variants.firstOrNull() + update { copy(subagent = item.key, variant = variant) } + } + + @RequiresEdt + private fun update(fn: ModelsDraft.() -> ModelsDraft) { + checkEdt() + draft = draft.fn() + sync() + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Models settings UI updates must run on EDT" } + } +} + +private const val KILO_PROVIDER = "kilo" + +private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } + +private fun title(value: String): String = value.replace('-', ' ').replace('_', ' ').replaceFirstChar { it.titlecase() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt new file mode 100644 index 00000000000..9ad34bc37a1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt @@ -0,0 +1,55 @@ +package ai.kilocode.client.settings.ui + +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.ui.JBColor +import com.intellij.ui.SeparatorComponent +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +class SettingsRow( + title: String, + description: String? = null, + value: JComponent, +) : JPanel(BorderLayout()) { + + init { + border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, UiStyle.Gap.pad(), 0) + + val labels = Stack.vertical(UiStyle.Gap.sm()) + labels.next(JBLabel(title).apply { font = UiStyle.Fonts.bold() }) + if (description != null) { + labels.next(JBLabel(description).apply { + font = UiStyle.Fonts.hint() + foreground = UIUtil.getContextHelpForeground() + }) + } + + add(labels, BorderLayout.CENTER) + add(value.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST) + } +} + +class SettingsRows : Stack(StackAxis.VERTICAL) { + private var rows = 0 + + fun row(child: SettingsRow): SettingsRows { + if (rows > 0) next(SeparatorComponent(0, JBColor.border(), null)) + next(child) + rows += 1 + return this + } + + override fun removeAll() { + rows = 0 + super.removeAll() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index e801b3b2c1a..66daeba58a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -25,10 +25,19 @@ + + - + + + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 96303b3c08a..84a7800851d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -190,6 +190,26 @@ action.Kilo.ShowProfile.description=Open Kilo user profile settings action.Kilo.ToolWindowToolbar.text=Kilo Toolbar settings.kilo.displayName=Kilo Code settings.kilo.description=Configure Kilo Code AI coding assistant features and account settings. +settings.models.displayName=Models +settings.models.defaultModel.title=Default Model +settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. +settings.models.smallModel.title=Small Model +settings.models.smallModel.description=The lightweight model used for tasks such as title generation. +settings.models.subagentModel.title=Subagent Model +settings.models.subagentModel.description=The default model for task-tool subagents. If unset, subagents inherit the calling mode model. +settings.models.subagentVariant.title=Reasoning Effort +settings.models.subagentVariant.description=The default reasoning effort for subagents when the selected model supports variants. +settings.models.modeModels.title=Model Per Mode +settings.models.modeModels.description=Override the model used by an individual Kilo mode. +settings.models.notSet=Not set +settings.models.loading=Loading models... +settings.models.unavailable=Models are unavailable until Kilo finishes loading. +settings.models.load.failed=Failed to load model settings +settings.models.noProviders=No providers available +settings.models.modes.failed=Failed to load per-mode settings +settings.models.save.failed=Failed to save model settings +settings.models.save.pending=Saving model settings... +settings.models.reset=Reset settings.profile.displayName=User Profile profile.group.account=Account profile.group.organization=Organization @@ -233,6 +253,10 @@ action.Kilo.StopSession.text=Stop Session action.Kilo.StopSession.description=Stop the current Kilo session action.Kilo.SettingsGroup.text=Settings action.Kilo.SettingsGroup.description=Kilo Code settings +action.Kilo.OpenSettings.text=Open Settings +action.Kilo.OpenSettings.description=Open Kilo Code settings +action.Kilo.CliGroup.text=CLI +action.Kilo.CliGroup.description=Kilo CLI actions action.Kilo.Restart.text=Restart Kilo action.Kilo.Restart.description=Kill and restart the CLI process action.Kilo.Reinstall.text=Reinstall Kilo diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt index c1ad8e5f07f..7970e3976be 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt @@ -136,6 +136,23 @@ class ModelPickerTest : BasePlatformTestCase() { assertEquals("openai/b", picker.selectedForTest()?.key) } + fun `test setItems auto-selects first item by default`() { + val picker = ModelPicker() + + picker.setItems(listOf(item("a", "A", "openai", "OpenAI"))) + + assertEquals("openai/a", picker.selectionKeyForTest()) + } + + fun `test allowEmpty keeps empty selection`() { + val picker = ModelPicker() + picker.allowEmpty = true + + picker.setItems(listOf(item("a", "A", "openai", "OpenAI"))) + + assertNull(picker.selectionKeyForTest()) + } + fun `test provider qualified default selects duplicate model id from correct provider`() { val picker = ModelPicker() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt index 68bc1de2a0a..cbc6147e52d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.settings.models.ModelsConfigurable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.SearchableConfigurable @@ -22,6 +23,10 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { assertEquals("ai.kilocode.jetbrains.settings.profile", UserProfileConfigurable.ID) } + fun `test child models id matches xml registration`() { + assertEquals("ai.kilocode.jetbrains.settings.models", ModelsConfigurable.ID) + } + fun `test root implements SearchableConfigurable but not Parent`() { // Root should be SearchableConfigurable so it can be found by ID, // but NOT SearchableConfigurable.Parent to avoid duplicating XML-registered child configurables. @@ -58,6 +63,24 @@ class KiloSettingsConfigurableTest : BasePlatformTestCase() { } } + fun `test createComponent contains Models link`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + val links = links(panel as Container) + assertTrue("expected a link labeled 'Models'", links.any { it.text == "Models" }) + } + } + + fun `test profile link appears before models link`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + val labels = links(panel as Container).map { it.text } + assertTrue("User Profile should appear before Models", labels.indexOf("User Profile") < labels.indexOf("Models")) + } + } + fun `test open invokes select with child found by id`() { // Verify that open() uses the correct ID constant to navigate val cfg = KiloSettingsConfigurable() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt new file mode 100644 index 00000000000..6c778c02723 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt @@ -0,0 +1,112 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.rpc.dto.AgentConfigDto +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ProvidersDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ModelsSettingsStateTest { + + @Test + fun `default model patch set and clear`() { + val from = ModelsDraft(model = null) + val set = ModelsDraft(model = "kilo/gpt-5") + assertEquals("kilo/gpt-5", patch(from, set).values["model"]) + assertNull(patch(set, from).values["model"]) + } + + @Test + fun `subagent clear clears variant`() { + val from = ModelsDraft(subagent = "kilo/gpt-5", variant = "high") + val to = ModelsDraft(subagent = null, variant = null) + val patch = patch(from, to) + assertNull(patch.values["subagent_model"]) + assertNull(patch.values["subagent_variant"]) + } + + @Test + fun `per-mode patch set and clear`() { + val from = ModelsDraft(agents = mapOf("ask" to null)) + val set = ModelsDraft(agents = mapOf("ask" to "openai/gpt")) + assertEquals("openai/gpt", patch(from, set).agents["ask"]?.model) + assertNull(patch(set, from).agents["ask"]?.model) + } + + @Test + fun `draft reads config agent values`() { + val agents = listOf(AgentDto(name = "ask", displayName = "Ask", mode = "ask")) + val config = ConfigDto( + model = "kilo/gpt-5", + smallModel = "kilo/auto-small", + subagentModel = "openai/gpt", + subagentVariant = "high", + agent = mapOf("ask" to AgentConfigDto(model = "kilo/gpt-5")), + ) + val draft = modelsDraft(config, agents) + assertEquals("kilo/gpt-5", draft.model) + assertEquals("kilo/auto-small", draft.small) + assertEquals("openai/gpt", draft.subagent) + assertEquals("high", draft.variant) + assertEquals("kilo/gpt-5", draft.agents["ask"]) + } + + @Test + fun `models status enables after providers load`() { + val status = modelsStatus( + ready = true, + loading = false, + providers = ProvidersDto(emptyList(), emptyList(), emptyMap()), + items = 1, + errors = emptyList(), + saving = false, + ) + + assertEquals(ModelsStatus.READY, status) + } + + @Test + fun `models status allows default settings when agents fail`() { + val status = modelsStatus( + ready = true, + loading = false, + providers = ProvidersDto(emptyList(), emptyList(), emptyMap()), + items = 1, + errors = listOf(LoadErrorDto(resource = "agents", detail = "boom")), + saving = false, + ) + + assertEquals(ModelsStatus.MODES_FAILED, status) + } + + @Test + fun `models status reports provider fetch failure`() { + val status = modelsStatus( + ready = true, + loading = false, + providers = null, + items = 0, + errors = listOf(LoadErrorDto(resource = "providers", detail = "boom")), + saving = false, + ) + + assertEquals(ModelsStatus.LOAD_FAILED, status) + } + + @Test + fun `models status reports app unavailable`() { + val status = modelsStatus( + ready = false, + loading = false, + providers = null, + items = 0, + errors = emptyList(), + saving = false, + ) + + assertEquals(ModelsStatus.UNAVAILABLE, status) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 8a001addf95..783047d5f3d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -1,6 +1,9 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.AgentConfigDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto @@ -31,6 +34,8 @@ class FakeAppRpcApi : KiloAppRpcApi { val selections = mutableListOf() val cleared = mutableListOf() val variants = mutableListOf() + val configPatches = mutableListOf() + var configUpdateError: Exception? = null var connected = false private set @@ -113,6 +118,30 @@ class FakeAppRpcApi : KiloAppRpcApi { return models } + override suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto { + assertNotEdt("updateConfig") + configUpdateError?.let { throw it } + configPatches.add(patch) + val current = state.value + val next = current.copy(config = applyPatch(current.config ?: ConfigDto(), patch)) + state.value = next + return next + } + + private fun applyPatch(config: ConfigDto, patch: ConfigPatchDto): ConfigDto { + val values = patch.values + val agents = patch.agents.entries.fold(config.agent) { acc, (name, item) -> + acc + (name to (acc[name] ?: AgentConfigDto()).copy(model = item.model)) + } + return config.copy( + model = if (values.containsKey("model")) values["model"] else config.model, + smallModel = if (values.containsKey("small_model")) values["small_model"] else config.smallModel, + subagentModel = if (values.containsKey("subagent_model")) values["subagent_model"] else config.subagentModel, + subagentVariant = if (values.containsKey("subagent_variant")) values["subagent_variant"] else config.subagentVariant, + agent = agents, + ) + } + var fakeProfile: ProfileDto? = null var fakeDeviceAuth = DeviceAuthDto(code = "TEST-1234", verificationUrl = "https://auth.kilo.ai/device") val orgProfiles = mutableMapOf() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 5b9c6a8c20f..8e1565a9a63 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +22,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) var reloads = 0 private set + var models = ModelsWorkspaceDto() var fileMatches = emptyList() var openResult = true val fileCalls = mutableListOf>() @@ -41,6 +43,11 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { reloads += 1 } + override suspend fun models(directory: String): ModelsWorkspaceDto { + assertNotEdt("models") + return models + } + override suspend fun files(directory: String, path: String): List { assertNotEdt("files") fileCalls.add(directory to path) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index 9411fefc880..fa1bb85079b 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.DeviceAuthDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto @@ -62,6 +63,9 @@ interface KiloAppRpcApi : RemoteApi { /** Persist a per-model reasoning variant selection. */ suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto + /** Patch global CLI config values. */ + suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto + /** Refresh the user profile and return the latest data, or null if not logged in. */ suspend fun refreshProfile(): ProfileDto? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index f5c476e5ae9..4fd04426ccb 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi @@ -38,6 +39,9 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Trigger a full reload of workspace data. */ suspend fun reload(directory: String) + /** Fetch only the providers and agents needed by Models settings. */ + suspend fun models(directory: String): ModelsWorkspaceDto + /** Resolve [path] to matching files, scoped primarily to [directory]. */ suspend fun files(directory: String, path: String): List diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index b766f252fcf..bf4ee1491ea 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -49,9 +49,23 @@ data class AgentConfigDto( @Serializable data class ConfigDto( val model: String? = null, + val smallModel: String? = null, + val subagentModel: String? = null, + val subagentVariant: String? = null, val agent: Map = emptyMap(), ) +@Serializable +data class ConfigPatchDto( + val values: Map = emptyMap(), + val agents: Map = emptyMap(), +) + +@Serializable +data class AgentConfigPatchDto( + val model: String? = null, +) + @Serializable data class ProfileOrganizationDto( val id: String, diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index 8b162af5d14..bd65cc6cc27 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -29,3 +29,10 @@ data class KiloWorkspaceStateDto( val error: String? = null, val errors: List = emptyList(), ) + +@Serializable +data class ModelsWorkspaceDto( + val providers: ProvidersDto? = null, + val agents: AgentsDto? = null, + val errors: List = emptyList(), +) From 50f4d28e07f5fb0c71d231d9eead8c0b370c05aa Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 17:20:11 -0400 Subject: [PATCH 049/201] fix(jetbrains): expand markdown code blocks --- .changeset/quiet-code-boxes.md | 5 + .../client/session/ui/style/SessionUiStyle.kt | 14 ++ .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 79 ++++++++-- .../kilocode/client/ui/md/MdViewHybridTest.kt | 136 +++++++++++++++++- 4 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 .changeset/quiet-code-boxes.md diff --git a/.changeset/quiet-code-boxes.md b/.changeset/quiet-code-boxes.md new file mode 100644 index 00000000000..e88a80fa4e3 --- /dev/null +++ b/.changeset/quiet-code-boxes.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show JetBrains markdown code blocks as multiline boxed editors that expand to their full height. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 433ad686227..da11b8c78c1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -79,6 +79,20 @@ object SessionUiStyle { const val USER_BORDER_HORIZONTAL_PADDING = 12 } + /** Markdown code block geometry inside assistant messages. */ + object Code { + const val BLOCK_GAP = 6 + const val MIN_ROWS = 1 + const val BORDER_WIDTH = 1 + const val VIEWPORT_TOP_PADDING = 6 + const val VIEWPORT_HORIZONTAL_PADDING = 8 + const val VIEWPORT_BOTTOM_PADDING = 0 + const val SCROLLBAR_HEIGHT = 12 + const val WIDTH_PADDING = 16 + + fun topPadding(): Int = VIEWPORT_TOP_PADDING + SCROLLBAR_HEIGHT + } + /** Permission card command preview limits. */ object Permission { const val COMMAND_LINES = 3 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index fe899a40e80..b8f42a8dabc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -1,7 +1,9 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.log.KiloLog +import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.fileTypes.PlainTextFileType @@ -24,6 +26,7 @@ import org.commonmark.node.Node import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import java.awt.Color +import java.awt.Dimension import java.awt.Font import javax.swing.Box import javax.swing.BoxLayout @@ -265,7 +268,7 @@ internal class MdViewHybrid( private fun addGap() { if (root.componentCount == 0) return - root.add(Box.createVerticalStrut(JBUI.scale(6))) + root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) } private fun addBlock(component: JComponent) { @@ -301,20 +304,70 @@ internal class MdViewHybrid( private fun codeBlock(text: String, lang: String?): JComponent { val opts = opts() + val value = text.trimEnd('\n') val field = runCatching { CodeField(file(lang), opts, text) }.getOrElse { err -> LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) textArea(text, opts) } - return JBScrollPane(field).apply { - border = JBUI.Borders.empty() + val height = codeHeight(field, value) + val width = codeWidth(field, value) + field.preferredSize = Dimension(width, height) + field.minimumSize = Dimension(0, height) + field.maximumSize = Dimension(Int.MAX_VALUE, height) + return object : JBScrollPane(field) { + override fun doLayout() { + super.doLayout() + val view = viewport.view ?: return + val size = viewport.extentSize + if (size.height <= 0 || view.height == size.height) return + view.setSize(view.width.coerceAtLeast(size.width), size.height) + } + }.apply { + border = JBUI.Borders.customLine(opts.tableBorder, SessionUiStyle.View.Code.BORDER_WIDTH) + viewportBorder = JBUI.Borders.empty( + SessionUiStyle.View.Code.topPadding(), + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + ) isOpaque = opts.opaque background = opts.preBg viewport.background = opts.preBg horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + isWheelScrollingEnabled = true + setOverlappingScrollBar(false) + horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) + horizontalScrollBar.isOpaque = true + verticalScrollBar.preferredSize = JBUI.emptySize() + val pad = viewportBorder.getBorderInsets(this) + val size = height + insets.top + insets.bottom + pad.top + pad.bottom + horizontalScrollBar.preferredSize.height + preferredSize = Dimension(0, size) + minimumSize = Dimension(0, size) + maximumSize = Dimension(Int.MAX_VALUE, size) } } + private fun codeWidth(component: JComponent, text: String): Int { + val metrics = component.getFontMetrics(component.font) + val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0 + return width + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) + } + + private fun codeHeight(component: JComponent, text: String): Int { + val count = text.lineSequence().count() + val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + val field = component as? CodeField + if (field != null) { + field.ensureWillComputePreferredSize() + val ed = field.getEditor(false) + val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height + return maxOf(field.preferredSize.height, line * rows) + } + val line = component.getFontMetrics(component.font).height + return line * rows + } + private fun textArea(text: String, opts: MdStyle) = JBTextArea(text.trimEnd('\n')).apply { isEditable = false lineWrap = false @@ -322,16 +375,23 @@ internal class MdViewHybrid( background = opts.preBg foreground = opts.preFg font = Font(opts.codeFont, Font.PLAIN, style.editorSize) - border = JBUI.Borders.empty(6, 8) + border = JBUI.Borders.empty( + SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + ) } private inner class CodeField(file: FileType, opts: MdStyle, value: String) : - com.intellij.ui.EditorTextField(ProjectManager.getInstance().defaultProject, file) { + com.intellij.ui.EditorTextField( + EditorFactory.getInstance().createDocument(value.trimEnd('\n')), + ProjectManager.getInstance().defaultProject, + file, + true, + false, + ) { init { setFontInheritedFromLAF(false) font = Font(opts.codeFont, Font.PLAIN, style.editorSize) - text = value.trimEnd('\n') - isViewer = true addSettingsProvider { ed -> style.applyToEditor(ed) ed.setBorder(JBUI.Borders.empty()) @@ -342,7 +402,8 @@ internal class MdViewHybrid( ed.scrollPane.viewport.background = opts.preBg ed.settings.isUseSoftWraps = false ed.settings.isAdditionalPageAtBottom = false - ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index da92e839eeb..0e9670737c5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -1,8 +1,11 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane +import java.awt.BorderLayout import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -27,11 +30,131 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(view.html().contains("")) } - fun `test fenced code block creates horizontal scroll pane`() { + fun `test fenced code block shows horizontal scrollbar as needed`() { view.set("```kotlin\nval value = 1\n```") val pane = scrolls().single() assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertTrue(pane.isWheelScrollingEnabled) + assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) + assertTrue(pane.horizontalScrollBar.isOpaque) + assertFalse(pane.isOverlappingScrollBar) + assertEquals(0, pane.verticalScrollBar.preferredSize.width) + } + + fun `test fenced code block preserves multiline editor text and height`() { + view.set("```kotlin\nval one = 1\nval two = 2\nval three = 3\n```") + val pane = scrolls().single() + val editor = editors().single() + val line = editor.getFontMetrics(editor.font).height + val ins = pane.insets + val pad = pane.viewportBorder.getBorderInsets(pane) + val bar = pane.horizontalScrollBar.preferredSize.height + + assertEquals("val one = 1\nval two = 2\nval three = 3", editor.text) + assertEquals(editor.preferredSize.height + ins.top + ins.bottom + pad.top + pad.bottom + bar, pane.preferredSize.height) + assertTrue(pane.preferredSize.height >= line * 3) + } + + fun `test fenced code block horizontal scrollbar has no bottom padding`() { + view.set("```kotlin\n${"x".repeat(500)}\n```") + val pane = scrolls().single() + val pad = pane.viewportBorder.getBorderInsets(pane) + + assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) + assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) + } + + fun `test short code block top padding balances hidden scrollbar space`() { + view.set("```text\n[ALICE, ANNA]\n```") + val pane = scrolls().single() + val pad = pane.viewportBorder.getBorderInsets(pane) + + assertTrue(pad.top > pane.horizontalScrollBar.preferredSize.height) + assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) + } + + fun `test fenced code block height is not capped`() { + val code = (1..24).joinToString("\n") { "val value$it = $it" } + view.set("```kotlin\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + val line = editor.getFontMetrics(editor.font).height + + assertTrue(pane.preferredSize.height >= line * 24) + } + + fun `test fenced code block lays out to full editor height`() { + val code = (1..30).joinToString("\n") { "val value$it = $it" } + view.set("```kotlin\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + + layout(width = 420) + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertTrue("scroll pane should use its full preferred height", pane.height >= pane.preferredSize.height) + assertTrue("editor should not be clipped vertically", editor.height >= editor.preferredSize.height) + } + + fun `test streaming fenced code block grows to full height`() { + view.append("```java\nclass Example {\n") + val initial = scrolls().single().preferredSize.height + + view.append((1..20).joinToString("\n", postfix = "\n") { "void method$it() {}" }) + view.append("}\n```") + val pane = scrolls().single() + val editor = editors().single() + layout(width = 420) + + assertTrue("code block should grow after streamed lines", pane.preferredSize.height > initial) + assertTrue("streamed editor should not be clipped vertically", editor.height >= editor.preferredSize.height) + } + + fun `test java code block with blank lines fits vertically`() { + val code = """ + import java.util.List; + import java.util.stream.Collectors; + + public class StreamsExample { + public static void main(String[] args) { + List names = List.of("Alice", "Bob", "Charlie", "David", "Anna"); + + List result = names.stream() + .filter(name -> name.startsWith("A")) + .map(String::toUpperCase) + .collect(Collectors.toList()); + + System.out.println(result); + } + } + """.trimIndent() + view.set("```java\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + + layout(width = 420) + + val line = editor.getFontMetrics(editor.font).height + val rows = editor.text.lineSequence().count() + assertTrue("java editor should reserve every document line", editor.preferredSize.height >= line * rows) + assertTrue("java editor should not be clipped vertically", editor.height >= editor.preferredSize.height) + assertTrue("java code block should not clip vertically", pane.height >= pane.preferredSize.height) + } + + fun `test fenced code block width is bounded and boxed`() { + view.set("```kotlin\n${"x".repeat(500)}\n```") + val pane = scrolls().single() + val editor = editors().single() + val ins = pane.border.getBorderInsets(pane) + + assertEquals(0, pane.preferredSize.width) + assertTrue(editor.preferredSize.width > pane.preferredSize.width) + assertTrue(pane.maximumSize.width > 1000) + assertTrue(ins.top > 0) + assertTrue(ins.left > 0) + assertEquals(pane.background, pane.viewport.background) } fun `test clear resets source and components`() { @@ -74,4 +197,15 @@ class MdViewHybridTest : BasePlatformTestCase() { } private fun scrolls(): List = (view.component as JPanel).components.filterIsInstance() + + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + private fun layout(width: Int) { + val host = JPanel(BorderLayout()) + host.add(view.component, BorderLayout.CENTER) + host.setSize(width, view.component.preferredSize.height) + host.doLayout() + view.component.doLayout() + scrolls().forEach { it.doLayout() } + } } From d437a4d3b5669940e5de1e4aa428f0217d16448b Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 17:24:50 -0400 Subject: [PATCH 050/201] refactor(jetbrains): refine empty session feedback UI --- .../session/ui/empty/EmptySessionFeedback.kt | 21 ++++++++++++++----- .../session/ui/empty/EmptySessionPanel.kt | 6 ++---- .../client/session/ui/empty/RecentsList.kt | 18 +++++++++------- .../resources/messages/KiloBundle.properties | 2 +- .../session/ui/EmptySessionPanelTest.kt | 2 +- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt index 48ca0fcc3e4..0fb883bd871 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt @@ -10,9 +10,11 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.ui.popup.Balloon import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.util.IconLoader +import com.intellij.openapi.util.Disposer import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.xml.util.XmlStringUtil import java.awt.Cursor @@ -25,19 +27,25 @@ import javax.swing.JComponent internal class EmptySessionFeedback( private val browse: (String) -> Unit, ) { + private var balloon: Balloon? = null + val button: JButton = FeedbackButton().apply { addActionListener { popup() } } @RequiresEdt private fun popup() { - lateinit var balloon: Balloon + balloon?.let { + it.hide() + return + } + val content = content { url -> browse(url) - balloon.hide() + balloon?.hide() } - val point = RelativePoint(button, Point(button.width / 2, button.height)) - balloon = JBPopupFactory.getInstance() + val point = RelativePoint(button, Point(button.width / 2, button.height + JBUI.scale(1))) + val popup = JBPopupFactory.getInstance() .createBalloonBuilder(content) .setHideOnClickOutside(true) .setHideOnKeyOutside(true) @@ -50,7 +58,10 @@ internal class EmptySessionFeedback( .setCornerRadius(UiStyle.Balloon.arc()) .createBalloon() - balloon.show(point, Balloon.Position.below) + balloon = popup + popup.setAnimationEnabled(false) + Disposer.register(popup) { balloon = null } + popup.show(point, Balloon.Position.below) } private class FeedbackButton : EmptySessionPanel.ShowHistoryButton(buttonHtml(), AllIcons.Ide.Feedback) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index bd91413f63a..9246b72fcfd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -58,7 +58,7 @@ class EmptySessionPanel( private var style = SessionEditorStyle.current() private val timer = Timer(ACTIVITY_MS) { syncActivity() } - private val recent = RecentsList(recents, controller) + internal val recent = RecentsList(recents, controller) private val historyButton = ShowHistoryButton().apply { addActionListener { history() } @@ -162,8 +162,6 @@ class EmptySessionPanel( internal fun showHistoryCursor() = historyButton.cursor.type - internal fun recentCursor() = recent.cursorType() - internal fun recentVisible() = recent.hasSessions() internal fun explanationText() = KiloBundle.message("session.empty.welcome") @@ -251,7 +249,7 @@ class EmptySessionPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style welcomeLabel.font = style.regularFont - recent.titleFont(style.smallFont) + recent.applyStyle(style) revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt index c3205b69cf8..75380c86918 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt @@ -9,6 +9,8 @@ import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem import ai.kilocode.client.session.history.itemAt import ai.kilocode.client.session.history.title +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle @@ -34,7 +36,7 @@ import javax.swing.ListSelectionModel internal class RecentsList( sessions: List, private val controller: SessionController, -) : BorderLayoutPanel() { +) : BorderLayoutPanel(), SessionEditorStyleTarget { private val model = DefaultListModel() private var hover = -1 private var snapshot = HistoryActivitySnapshot() @@ -43,7 +45,7 @@ internal class RecentsList( foreground = UIUtil.getContextHelpForeground() } - private val list = JBList(model).apply { + internal val list = JBList(model).apply { isOpaque = false selectionMode = ListSelectionModel.SINGLE_SELECTION visibleRowCount = SessionUiStyle.RecentSessions.LIMIT @@ -93,12 +95,6 @@ internal class RecentsList( controller.openSession(SessionRef.Local(model.getElementAt(index).session)) } - fun cursorType() = list.cursor.type - - fun titleFont(font: java.awt.Font) { - title.font = font - } - fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = HistoryTime.relative(LocalHistoryItem(session), now) @@ -122,6 +118,12 @@ internal class RecentsList( repaintRows(changed) } + override fun applyStyle(style: SessionEditorStyle) { + title.font = style.smallFont + revalidate() + repaint() + } + private fun setSessions(sessions: List) { model.clear() sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index eeca886cc60..050eee20982 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -11,7 +11,7 @@ session.account.switcher=Switch account session.empty.loading=Loading... session.empty.recent=RECENT session.showHistory=Show History -feedback.button=Feedback & Support +feedback.button=Feedback and Support feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing. feedback.dialog.github=Report an issue on GitHub feedback.dialog.discord=Join our Discord community diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 1514d0d842a..d0c6f0b0eab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -175,7 +175,7 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertFalse(panel.feedbackBorderPainted()) assertEquals(Cursor.HAND_CURSOR, panel.showHistoryCursor()) assertEquals(Cursor.HAND_CURSOR, panel.feedbackCursor()) - assertEquals(Cursor.HAND_CURSOR, panel.recentCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.recent.list.cursor.type) } fun `test clicking show history delegates callback`() { From cb99e77ca027904ca93d00c78deef72544e6025d Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 17:34:09 -0400 Subject: [PATCH 051/201] fix(jetbrains): dispose empty session feedback balloon --- .../client/session/ui/empty/EmptySessionFeedback.kt | 9 +++++++-- .../client/session/ui/empty/EmptySessionPanel.kt | 3 +-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt index 0fb883bd871..ad6f79d05bb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt @@ -7,10 +7,11 @@ import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable import com.intellij.openapi.ui.popup.Balloon import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.util.IconLoader import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt @@ -26,7 +27,7 @@ import javax.swing.JComponent internal class EmptySessionFeedback( private val browse: (String) -> Unit, -) { +) : Disposable { private var balloon: Balloon? = null val button: JButton = FeedbackButton().apply { @@ -66,6 +67,10 @@ internal class EmptySessionFeedback( private class FeedbackButton : EmptySessionPanel.ShowHistoryButton(buttonHtml(), AllIcons.Ide.Feedback) + override fun dispose() { + balloon?.hide() + } + private class ActionButton(text: String, icon: javax.swing.Icon, action: () -> Unit) : JButton(text, icon) { init { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index 9246b72fcfd..29ad9df649f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -56,7 +56,6 @@ class EmptySessionPanel( ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { val view: Align = align(HAlign.CENTER, VAlign.CENTER) - private var style = SessionEditorStyle.current() private val timer = Timer(ACTIVITY_MS) { syncActivity() } internal val recent = RecentsList(recents, controller) @@ -90,6 +89,7 @@ class EmptySessionPanel( init { Disposer.register(parent, this) + Disposer.register(this, feedback) isOpaque = false applyStyle(SessionEditorStyle.current()) addHierarchyListener { e -> @@ -247,7 +247,6 @@ class EmptySessionPanel( } override fun applyStyle(style: SessionEditorStyle) { - this.style = style welcomeLabel.font = style.regularFont recent.applyStyle(style) revalidate() From 8bbefb33001c0df5197be01a159fe5134026d838 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 18:02:30 -0400 Subject: [PATCH 052/201] fix(jetbrains): avoid config update SSE race --- .../kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 5a0c14126ea..b37041af362 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -23,6 +23,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -343,6 +344,7 @@ class KiloBackendAppService private constructor( sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) + startWatchingGlobalSseEvents() setAppReady( AppData( profile = prof, @@ -352,7 +354,6 @@ class KiloBackendAppService private constructor( ) ) log.info("Application started — config, profile, notifications loaded") - startWatchingGlobalSseEvents() } catch (e: TimeoutCancellationException) { val err = LoadError( resource = "app", @@ -592,7 +593,7 @@ class KiloBackendAppService private constructor( synchronized(loadLock) { if (eventWatcher?.isActive == true) return log.info("Started watching global SSE events (config.updated, disposed)") - eventWatcher = cs.launch { + eventWatcher = cs.launch(start = CoroutineStart.UNDISPATCHED) { connection.events.collect { event -> when (event.type) { "global.config.updated" -> { From 746b33e0ed380fe4a71e49da6842c1f4de557100 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 18:11:36 -0400 Subject: [PATCH 053/201] fix(jetbrains): expand markdown code block language resolution --- .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 99 +++++++++++++++---- .../kilocode/client/ui/md/MdViewHybridTest.kt | 40 ++++++++ 2 files changed, 120 insertions(+), 19 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index b8f42a8dabc..1ca17be3614 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -5,8 +5,9 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.log.KiloLog import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileTypes.FileType -import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.openapi.project.ProjectManager import com.intellij.ui.components.JBTextArea import com.intellij.ui.components.JBHtmlPane @@ -42,6 +43,79 @@ internal class MdViewHybrid( ) : MdView { companion object { private val LOG = KiloLog.create(MdViewHybrid::class.java) + + private val FILES = mapOf( + "kt" to "kt", + "kotlin" to "kt", + "js" to "js", + "javascript" to "js", + "jsx" to "jsx", + "ts" to "ts", + "typescript" to "ts", + "tsx" to "tsx", + "java" to "java", + "py" to "py", + "python" to "py", + "sh" to "sh", + "bash" to "sh", + "shell" to "sh", + "json" to "json", + "xml" to "xml", + "html" to "html", + "css" to "css", + "md" to "md", + "markdown" to "md", + "yaml" to "yaml", + "yml" to "yaml", + "toml" to "toml", + "go" to "go", + "golang" to "go", + "rs" to "rs", + "rust" to "rs", + "rb" to "rb", + "ruby" to "rb", + "php" to "php", + "swift" to "swift", + "scala" to "scala", + "sql" to "sql", + "dockerfile" to "dockerfile", + "docker" to "dockerfile", + "gradle" to "gradle", + "kts" to "kts", + "c" to "c", + "h" to "h", + "cpp" to "cpp", + "c++" to "cpp", + "cc" to "cc", + "cxx" to "cxx", + "hpp" to "hpp", + "h++" to "hpp", + "cs" to "cs", + "csharp" to "cs", + "c#" to "cs", + "fs" to "fs", + "fsharp" to "fs", + "f#" to "fs", + "ps1" to "ps1", + "powershell" to "ps1", + "pwsh" to "ps1", + "bat" to "bat", + "batch" to "bat", + "cmd" to "bat", + "makefile" to "makefile", + "make" to "makefile", + "terraform" to "tf", + "tf" to "tf", + "hcl" to "hcl", + "vue" to "vue", + "svelte" to "svelte", + "graphql" to "graphql", + "proto" to "proto", + "ini" to "ini", + "properties" to "properties", + "diff" to "diff", + "patch" to "patch", + ) } private val listeners = mutableListOf() @@ -409,24 +483,11 @@ internal class MdViewHybrid( } private fun file(lang: String?): FileType { - val key = lang?.trim()?.substringBefore(' ')?.lowercase().orEmpty() - val ext = when (key) { - "kt", "kotlin" -> "kt" - "js", "javascript" -> "js" - "ts", "typescript" -> "ts" - "tsx" -> "tsx" - "java" -> "java" - "py", "python" -> "py" - "sh", "bash", "shell" -> "sh" - "json" -> "json" - "xml" -> "xml" - "html" -> "html" - "css" -> "css" - "md", "markdown" -> "md" - else -> "" - } - if (ext.isEmpty()) return PlainTextFileType.INSTANCE - return FileTypeManager.getInstance().getFileTypeByExtension(ext) + val key = lang?.trim()?.split(Regex("\\s+"))?.firstOrNull()?.lowercase().orEmpty() + val ext = FILES[key] ?: return PlainTextFileType.INSTANCE + val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type } private fun dispatch(event: MdView.LinkEvent) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 0e9670737c5..1c821b16ea4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -2,6 +2,10 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeRegistry +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane @@ -143,6 +147,36 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue("java code block should not clip vertically", pane.height >= pane.preferredSize.height) } + fun `test fenced code block resolves existing language aliases`() { + view.set("```javascript\nconst value = 1\n```") + + assertSame(type("js"), editors().single().fileType) + } + + fun `test fenced code block ignores whitespace metadata`() { + view.set("```json title=\"sample.json\"\n{\"value\":1}\n```") + + assertSame(type("json"), editors().single().fileType) + } + + fun `test fenced code block resolves new aliases when available`() { + view.set("```yaml\nvalue: 1\n```") + + assertSame(type("yaml"), editors().single().fileType) + } + + fun `test unknown fenced code language uses plain text`() { + view.set("```definitely-not-a-language\nvalue\n```") + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + } + + fun `test code block without language uses plain text`() { + view.set("```\nvalue\n```") + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + } + fun `test fenced code block width is bounded and boxed`() { view.set("```kotlin\n${"x".repeat(500)}\n```") val pane = scrolls().single() @@ -200,6 +234,12 @@ class MdViewHybridTest : BasePlatformTestCase() { private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + private fun type(ext: String): FileType { + val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type + } + private fun layout(width: Int) { val host = JPanel(BorderLayout()) host.add(view.component, BorderLayout.CENTER) From 910f4a6c3d73dc5d7cdd375fc4eea884a3ef383b Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 18:23:32 -0400 Subject: [PATCH 054/201] fix(kilo-jetbrains): show sign-in banner in models settings --- .../settings/models/ModelsSettingsState.kt | 4 +- .../settings/models/ModelsSettingsUi.kt | 47 ++++++++++++++++++- .../resources/messages/KiloBundle.properties | 2 + .../models/ModelsSettingsStateTest.kt | 23 +++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt index b7fa01d4027..b13fc9b8445 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt @@ -70,8 +70,10 @@ internal fun modelsStatus( if (saving) return ModelsStatus.SAVING if (!ready) return ModelsStatus.UNAVAILABLE if (loading) return ModelsStatus.LOADING - if (providers == null) return if (errors.isNotEmpty()) ModelsStatus.LOAD_FAILED else ModelsStatus.LOADING + if (providers == null) return ModelsStatus.LOAD_FAILED if (items == 0) return ModelsStatus.NO_PROVIDERS if (errors.any { it.resource == "agents" }) return ModelsStatus.MODES_FAILED return ModelsStatus.READY } + +internal fun modelsLoginBannerVisible(ready: Boolean, authenticated: Boolean): Boolean = ready && !authenticated diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index e52c8f03791..b0e282013f3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.model.ModelText +import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.settings.profile.edt import ai.kilocode.client.settings.ui.SettingsRow import ai.kilocode.client.settings.ui.SettingsRows @@ -20,8 +21,16 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.ProvidersDto +import com.intellij.ide.DataManager import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableWithId +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.options.ex.Settings +import com.intellij.openapi.project.ProjectManager +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.InlineBanner import com.intellij.ui.TitledSeparator import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane @@ -33,6 +42,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BorderLayout +import java.util.function.Predicate +import javax.swing.JComponent import javax.swing.JPanel internal class ModelsSettingsUi( @@ -43,6 +54,10 @@ internal class ModelsSettingsUi( ) : JPanel(BorderLayout()) { private val status = JBLabel(KiloBundle.message("settings.models.loading")) + private val banner = InlineBanner( + KiloBundle.message("settings.models.login.message"), + EditorNotificationPanel.Status.Warning, + ).showCloseButton(false) private val content = Stack.vertical() private val rows = SettingsRows() private val modes = SettingsRows() @@ -76,6 +91,9 @@ internal class ModelsSettingsUi( picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } } + banner.addAction(KiloBundle.message("settings.models.login.action"), Runnable { openProfile(banner) }) + content.next(banner) + content.gap(UiStyle.Gap.md()) content.next(status) content.gap(UiStyle.Gap.lg()) content.next(TitledSeparator(KiloBundle.message("settings.models.displayName"))) @@ -240,15 +258,20 @@ internal class ModelsSettingsUi( checkEdt() allItems = items(false) val smallItems = items(true) + val hasDir = dir != null || directory != null val state = modelsStatus( - ready = appState.status == KiloAppStatusDto.READY, - loading = loading || (appState.status == KiloAppStatusDto.READY && !loaded && dir != null), + ready = appState.status == KiloAppStatusDto.READY && hasDir, + loading = loading || (appState.status == KiloAppStatusDto.READY && !loaded && hasDir), providers = providers, items = allItems.size, errors = errors, saving = saving, ) val ready = state == ModelsStatus.READY || state == ModelsStatus.MODES_FAILED + banner.isVisible = modelsLoginBannerVisible( + ready = appState.status == KiloAppStatusDto.READY, + authenticated = appState.profile != null, + ) if (!saving) { when (state) { ModelsStatus.UNAVAILABLE -> { @@ -348,6 +371,26 @@ internal class ModelsSettingsUi( update { copy(subagent = item.key, variant = variant) } } + private fun openProfile(src: JComponent) { + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) + if (settings != null) { + val cfg = settings.find(UserProfileConfigurable.ID) + if (cfg != null) { + settings.select(cfg) + return + } + } + + val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault } + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID + }, + { cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) }, + ) + } + @RequiresEdt private fun update(fn: ModelsDraft.() -> ModelsDraft) { checkEdt() diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 84a7800851d..de0755a1ae2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -207,6 +207,8 @@ settings.models.unavailable=Models are unavailable until Kilo finishes loading. settings.models.load.failed=Failed to load model settings settings.models.noProviders=No providers available settings.models.modes.failed=Failed to load per-mode settings +settings.models.login.message=Sign in to Kilo Code to access account-backed models and manage billing. +settings.models.login.action=Open User Profile settings.models.save.failed=Failed to save model settings settings.models.save.pending=Saving model settings... settings.models.reset=Reset diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt index 6c778c02723..8a1b8255dde 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt @@ -7,7 +7,9 @@ import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ProvidersDto import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class ModelsSettingsStateTest { @@ -96,6 +98,20 @@ class ModelsSettingsStateTest { assertEquals(ModelsStatus.LOAD_FAILED, status) } + @Test + fun `models status reports missing providers after loading completes`() { + val status = modelsStatus( + ready = true, + loading = false, + providers = null, + items = 0, + errors = emptyList(), + saving = false, + ) + + assertEquals(ModelsStatus.LOAD_FAILED, status) + } + @Test fun `models status reports app unavailable`() { val status = modelsStatus( @@ -109,4 +125,11 @@ class ModelsSettingsStateTest { assertEquals(ModelsStatus.UNAVAILABLE, status) } + + @Test + fun `login banner shows only when ready and unauthenticated`() { + assertTrue(modelsLoginBannerVisible(ready = true, authenticated = false)) + assertFalse(modelsLoginBannerVisible(ready = true, authenticated = true)) + assertFalse(modelsLoginBannerVisible(ready = false, authenticated = false)) + } } From bee54755fffd0bcd41f4a6268fb88cb306dd426f Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 18:29:22 -0400 Subject: [PATCH 055/201] fix(jetbrains): dispose embedded markdown editors --- .../ai/kilocode/client/session/SessionUi.kt | 13 ++++- .../session/ui/SessionMessageListPanel.kt | 24 +++++++- .../client/session/views/MessageView.kt | 21 ++++++- .../client/session/views/PlanExitView.kt | 2 + .../client/session/views/ReasoningView.kt | 2 + .../kilocode/client/session/views/TextView.kt | 2 + .../kilocode/client/session/views/TurnView.kt | 13 ++++- .../client/session/views/base/PartView.kt | 5 +- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 3 +- .../kilocode/client/ui/md/MdViewHtmlPane.kt | 30 ++++++++++ .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 55 ++++++++++++++++++- .../kilocode/client/ui/md/MdViewHybridTest.kt | 49 +++++++++++++++++ 12 files changed, 209 insertions(+), 10 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 194d118a7cd..193d6031864 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -145,6 +145,7 @@ class SessionUi( private var style = SessionEditorStyle.current() private var editorTheme = style.editorScheme private var colorTheme = UIManager.getLookAndFeel() + private var disposed = false init { buildUi() @@ -158,12 +159,14 @@ class SessionUi( } override fun addNotify() { + if (disposed) return super.addNotify() resumeOpen() } override fun doLayout() { super.doLayout() + if (disposed) return resumeOpen() } @@ -499,6 +502,7 @@ class SessionUi( } private fun onStateChanged(state: SessionState) { + if (disposed) return prompt.setBusy(state.isBusy()) load.setState(state) scroll.setQuestionPending(questionPending(state)) @@ -512,6 +516,7 @@ class SessionUi( } private fun refresh() { + if (disposed) return scroll.refresh() root.revalidate() root.repaint() @@ -549,7 +554,13 @@ class SessionUi( ) } - override fun dispose() {} + override fun dispose() { + disposed = true + modalFocus = null + empty = null + if (this::root.isInitialized) root.setModalContent(null) + removeAll() + } } private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index f21a2b669bc..60977888672 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TurnView import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI /** @@ -56,7 +57,7 @@ class SessionMessageListPanel( SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, ), -), SessionEditorStyleTarget { +), Disposable, SessionEditorStyleTarget { private val turnViews = LinkedHashMap() private val msgToTurn = HashMap() @@ -69,6 +70,7 @@ class SessionMessageListPanel( init { isOpaque = false + Disposer.register(parent, this) model.addListener(parent) { event -> when (event) { @@ -215,11 +217,16 @@ class SessionMessageListPanel( val tv = turnViews.remove(id) ?: return for (msgId in tv.messageIds()) unregister(msgId) remove(tv) + Disposer.dispose(tv) anchorFooter() refresh() } private fun rebuild() { + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } turnViews.clear() msgToTurn.clear() msgToView.clear() @@ -242,6 +249,10 @@ class SessionMessageListPanel( } private fun clear() { + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } turnViews.clear() msgToTurn.clear() msgToView.clear() @@ -336,4 +347,15 @@ class SessionMessageListPanel( progress.applyStyle(style) refresh() } + + override fun dispose() { + turnViews.values.forEach { + remove(it) + Disposer.dispose(it) + } + turnViews.clear() + msgToTurn.clear() + msgToView.clear() + removeAll() + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index efe87c02880..c753a761243 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -11,6 +11,8 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI import java.awt.Graphics import java.awt.Graphics2D @@ -34,7 +36,7 @@ class MessageView( private val openUrl: (String) -> Unit = {}, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), -), SessionEditorStyleTarget, SessionView { +), Disposable, SessionEditorStyleTarget, SessionView { constructor(msg: Message, openFile: (String) -> Unit) : this(msg, openFile, SessionEditorStyle.current()) @@ -84,6 +86,7 @@ class MessageView( val stale = parts.remove(content.id) if (stale != null) { remove(stale) + Disposer.dispose(stale) syncBorder() refresh() } @@ -111,6 +114,7 @@ class MessageView( val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) remove(existing) + Disposer.dispose(existing) val view = view(content) view.applyStyle(style) parts[content.id] = view @@ -123,6 +127,7 @@ class MessageView( fun removePart(contentId: String) { val view = parts.remove(contentId) ?: return remove(view) + Disposer.dispose(view) syncBorder() refresh() } @@ -146,7 +151,10 @@ class MessageView( * Called only when the hidden ref changes to avoid unnecessary rebuilds. */ private fun rebuildParts() { - parts.values.forEach { remove(it) } + parts.values.forEach { + remove(it) + Disposer.dispose(it) + } parts.clear() for ((_, content) in msg.parts) { if (content is StepFinish) continue @@ -194,6 +202,15 @@ class MessageView( refresh() } + override fun dispose() { + parts.values.forEach { + remove(it) + Disposer.dispose(it) + } + parts.clear() + hidden = null + } + override fun paintComponent(g: Graphics) { if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE) { super.paintComponent(g) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index 3d036f9514c..70afc6fdf5f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.util.Disposer import java.awt.BorderLayout class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() { @@ -22,6 +23,7 @@ class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() { init { layout = BorderLayout() isOpaque = false + Disposer.register(this, md) md.addLinkListener { openFile(it.href) } add(md.component, BorderLayout.CENTER) applyStyle(SessionEditorStyle.current()) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index f32ad1654cd..790fc88b492 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory import com.intellij.icons.AllIcons +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI @@ -39,6 +40,7 @@ class ReasoningView( private var source = reasoning.content.toString() init { + Disposer.register(this, md) bindHeader(parts.title, parts.icon) applyStyle(style) md.opaque = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index facaa4028f5..a002af8415f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.util.Disposer import java.awt.BorderLayout /** @@ -26,6 +27,7 @@ class TextView( init { layout = BorderLayout() isOpaque = false + Disposer.register(this, md) md.opaque = !transparent md.addLinkListener { openUrl(it.href) } applyStyle(SessionEditorStyle.current()) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 1b1d7283ead..02d8db97b63 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -5,6 +5,8 @@ import ai.kilocode.client.session.ui.SessionLayoutPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI /** @@ -21,7 +23,7 @@ class TurnView( private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, -) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget { +) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -44,6 +46,7 @@ class TurnView( fun removeMessage(msgId: String) { val view = messages.remove(msgId) ?: return remove(view) + Disposer.dispose(view) revalidate() } @@ -62,4 +65,12 @@ class TurnView( revalidate() repaint() } + + override fun dispose() { + messages.values.forEach { + remove(it) + Disposer.dispose(it) + } + messages.clear() + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 9cfacf9b41c..41d4d57853a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import com.intellij.openapi.Disposable import javax.swing.JPanel /** @@ -14,7 +15,7 @@ import javax.swing.JPanel * * All methods must be called on the EDT. */ -abstract class PartView : JPanel(), SessionEditorStyleTarget { +abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { /** Stable [Content.id] this renderer was created for. */ abstract val contentId: String @@ -34,6 +35,8 @@ abstract class PartView : JPanel(), SessionEditorStyleTarget { override fun applyStyle(style: SessionEditorStyle) {} + override fun dispose() {} + /** Readable name for test dumps. */ open fun dumpLabel(): String = javaClass.simpleName } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 8f456b01db7..7b49c51d716 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,13 +1,14 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.Disposable import java.awt.Color import java.awt.Font import java.awt.Point import javax.swing.JComponent /** Markdown rendering component. All public methods must be called on the EDT. */ -interface MdView { +interface MdView : Disposable { val component: JComponent fun set(text: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt index 22cbe9ce479..7644da58530 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt @@ -28,6 +28,7 @@ internal class MdViewHtmlPane( private val source = StringBuilder() private var rendered = "" private var style = style + private var disposed = false private val extensions = listOf( AutolinkExtension.create(), @@ -82,6 +83,7 @@ internal class MdViewHtmlPane( override var font: Font get() = fontOverride ?: opts().font set(value) { + if (disposed) return if (fontOverride == value) return fontOverride = value markDirty() @@ -90,6 +92,7 @@ internal class MdViewHtmlPane( override var foreground: Color get() = foregroundOverride ?: opts().foreground set(value) { + if (disposed) return if (foregroundOverride == value) return foregroundOverride = value markDirty() @@ -98,6 +101,7 @@ internal class MdViewHtmlPane( override var background: Color get() = backgroundOverride ?: opts().background set(value) { + if (disposed) return if (backgroundOverride == value) return backgroundOverride = value if (opaqueState) pane.background = value @@ -107,6 +111,7 @@ internal class MdViewHtmlPane( override var linkColor: Color get() = linkColorOverride ?: opts().linkColor set(value) { + if (disposed) return if (linkColorOverride == value) return linkColorOverride = value markDirty() @@ -115,6 +120,7 @@ internal class MdViewHtmlPane( override var codeBg: Color get() = codeBgOverride ?: opts().codeBg set(value) { + if (disposed) return if (codeBgOverride == value) return codeBgOverride = value markDirty() @@ -123,6 +129,7 @@ internal class MdViewHtmlPane( override var preBg: Color get() = preBgOverride ?: opts().preBg set(value) { + if (disposed) return if (preBgOverride == value) return preBgOverride = value markDirty() @@ -131,6 +138,7 @@ internal class MdViewHtmlPane( override var preFg: Color get() = preFgOverride ?: opts().preFg set(value) { + if (disposed) return if (preFgOverride == value) return preFgOverride = value markDirty() @@ -139,6 +147,7 @@ internal class MdViewHtmlPane( override var codeFont: String get() = codeFontOverride ?: opts().codeFont set(value) { + if (disposed) return if (codeFontOverride == value) return codeFontOverride = value markDirty() @@ -147,6 +156,7 @@ internal class MdViewHtmlPane( override var quoteBorder: Color get() = quoteBorderOverride ?: opts().quoteBorder set(value) { + if (disposed) return if (quoteBorderOverride == value) return quoteBorderOverride = value markDirty() @@ -155,6 +165,7 @@ internal class MdViewHtmlPane( override var quoteFg: Color get() = quoteFgOverride ?: opts().quoteFg set(value) { + if (disposed) return if (quoteFgOverride == value) return quoteFgOverride = value markDirty() @@ -163,6 +174,7 @@ internal class MdViewHtmlPane( override var tableBorder: Color get() = tableBorderOverride ?: opts().tableBorder set(value) { + if (disposed) return if (tableBorderOverride == value) return tableBorderOverride = value markDirty() @@ -171,6 +183,7 @@ internal class MdViewHtmlPane( override var opaque: Boolean get() = opaqueState set(value) { + if (disposed) return if (opaqueState == value) return opaqueState = value pane.isOpaque = value @@ -179,6 +192,7 @@ internal class MdViewHtmlPane( } override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return if (this.style == style) return this.style = style if (opaqueState) pane.background = background @@ -186,6 +200,7 @@ internal class MdViewHtmlPane( } override fun resetStyles() { + if (disposed) return fontOverride = null foregroundOverride = null backgroundOverride = null @@ -204,6 +219,7 @@ internal class MdViewHtmlPane( } override fun set(text: String) { + if (disposed) return if (source.toString() == text) return source.clear() source.append(text) @@ -211,12 +227,14 @@ internal class MdViewHtmlPane( } override fun append(delta: String) { + if (disposed) return if (delta.isEmpty()) return source.append(delta) syncHtml() } override fun clear() { + if (disposed) return if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return source.clear() rendered = "" @@ -224,6 +242,7 @@ internal class MdViewHtmlPane( } override fun addLinkListener(listener: MdView.LinkListener) { + if (disposed) return listeners.add(listener) } @@ -238,19 +257,30 @@ internal class MdViewHtmlPane( override fun overrideSheet(): String = MdCommon.rules(opts()) override fun simulateLink(href: String) { + if (disposed) return dispatch(MdView.LinkEvent(href)) } + override fun dispose() { + disposed = true + listeners.clear() + source.clear() + rendered = "" + pane.text = "" + } + private fun dispatch(event: MdView.LinkEvent) { for (l in listeners) l.onLink(event) } private fun markDirty() { + if (disposed) return pane.reloadCssStylesheets() if (source.isNotEmpty()) syncHtml() } private fun syncHtml() { + if (disposed) return val body = renderer.render(parser.parse(source.toString())) if (rendered == body && pane.text == "$body") return rendered = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index 1ca17be3614..302ad0a8013 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -3,12 +3,14 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.log.KiloLog +import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBTextArea import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration @@ -122,6 +124,8 @@ internal class MdViewHybrid( private val source = StringBuilder() private var style = style private var rendered = "" + private var block: Disposable? = null + private var disposed = false private val extensions = listOf( AutolinkExtension.create(), @@ -161,6 +165,7 @@ internal class MdViewHybrid( override var font: Font get() = fontOverride ?: opts().font set(value) { + if (disposed) return if (fontOverride == value) return fontOverride = value syncStyle() @@ -169,6 +174,7 @@ internal class MdViewHybrid( override var foreground: Color get() = foregroundOverride ?: opts().foreground set(value) { + if (disposed) return if (foregroundOverride == value) return foregroundOverride = value syncStyle() @@ -177,6 +183,7 @@ internal class MdViewHybrid( override var background: Color get() = backgroundOverride ?: opts().background set(value) { + if (disposed) return if (backgroundOverride == value) return backgroundOverride = value syncStyle() @@ -185,6 +192,7 @@ internal class MdViewHybrid( override var linkColor: Color get() = linkColorOverride ?: opts().linkColor set(value) { + if (disposed) return if (linkColorOverride == value) return linkColorOverride = value syncStyle() @@ -193,6 +201,7 @@ internal class MdViewHybrid( override var codeBg: Color get() = codeBgOverride ?: opts().codeBg set(value) { + if (disposed) return if (codeBgOverride == value) return codeBgOverride = value syncStyle() @@ -201,6 +210,7 @@ internal class MdViewHybrid( override var preBg: Color get() = preBgOverride ?: opts().preBg set(value) { + if (disposed) return if (preBgOverride == value) return preBgOverride = value syncStyle() @@ -209,6 +219,7 @@ internal class MdViewHybrid( override var preFg: Color get() = preFgOverride ?: opts().preFg set(value) { + if (disposed) return if (preFgOverride == value) return preFgOverride = value syncStyle() @@ -217,6 +228,7 @@ internal class MdViewHybrid( override var codeFont: String get() = codeFontOverride ?: opts().codeFont set(value) { + if (disposed) return if (codeFontOverride == value) return codeFontOverride = value syncStyle() @@ -225,6 +237,7 @@ internal class MdViewHybrid( override var quoteBorder: Color get() = quoteBorderOverride ?: opts().quoteBorder set(value) { + if (disposed) return if (quoteBorderOverride == value) return quoteBorderOverride = value syncStyle() @@ -233,6 +246,7 @@ internal class MdViewHybrid( override var quoteFg: Color get() = quoteFgOverride ?: opts().quoteFg set(value) { + if (disposed) return if (quoteFgOverride == value) return quoteFgOverride = value syncStyle() @@ -241,6 +255,7 @@ internal class MdViewHybrid( override var tableBorder: Color get() = tableBorderOverride ?: opts().tableBorder set(value) { + if (disposed) return if (tableBorderOverride == value) return tableBorderOverride = value syncStyle() @@ -249,18 +264,21 @@ internal class MdViewHybrid( override var opaque: Boolean get() = opaqueState set(value) { + if (disposed) return if (opaqueState == value) return opaqueState = value syncStyle() } override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return if (this.style == style) return this.style = style syncStyle() } override fun resetStyles() { + if (disposed) return fontOverride = null foregroundOverride = null backgroundOverride = null @@ -277,6 +295,7 @@ internal class MdViewHybrid( } override fun set(text: String) { + if (disposed) return if (source.toString() == text) return source.clear() source.append(text) @@ -284,21 +303,24 @@ internal class MdViewHybrid( } override fun append(delta: String) { + if (disposed) return if (delta.isEmpty()) return source.append(delta) syncBlocks() } override fun clear() { + if (disposed) return if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return source.clear() rendered = "" - root.removeAll() + clearBlocks() root.revalidate() root.repaint() } override fun addLinkListener(listener: MdView.LinkListener) { + if (disposed) return listeners.add(listener) } @@ -313,10 +335,20 @@ internal class MdViewHybrid( override fun overrideSheet(): String = MdCommon.rules(opts()) override fun simulateLink(href: String) { + if (disposed) return dispatch(MdView.LinkEvent(href)) } + override fun dispose() { + disposed = true + listeners.clear() + source.clear() + rendered = "" + clearBlocks() + } + private fun syncStyle() { + if (disposed) return val opts = opts() root.isOpaque = opts.opaque if (opts.opaque) root.background = opts.background @@ -324,11 +356,12 @@ internal class MdViewHybrid( } private fun syncBlocks() { + if (disposed) return val text = source.toString() val doc = parser.parse(text) val body = renderer.render(doc) rendered = body - root.removeAll() + resetBlocks() if (text.isEmpty()) { root.revalidate() root.repaint() @@ -340,6 +373,18 @@ internal class MdViewHybrid( root.repaint() } + private fun resetBlocks() { + block?.let { Disposer.dispose(it) } + block = Disposer.newDisposable("Markdown blocks") + root.removeAll() + } + + private fun clearBlocks() { + block?.let { Disposer.dispose(it) } + block = null + root.removeAll() + } + private fun addGap() { if (root.componentCount == 0) return root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) @@ -379,7 +424,11 @@ internal class MdViewHybrid( private fun codeBlock(text: String, lang: String?): JComponent { val opts = opts() val value = text.trimEnd('\n') - val field = runCatching { CodeField(file(lang), opts, text) }.getOrElse { err -> + val field = runCatching { + CodeField(file(lang), opts, text).also { ed -> + block?.let { ed.setDisposedWith(it) } + } + }.getOrElse { err -> LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) textArea(text, opts) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 1c821b16ea4..47f4e4368c6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -6,9 +6,11 @@ import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -16,10 +18,20 @@ import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") class MdViewHybridTest : BasePlatformTestCase() { private lateinit var view: MdView + private var disposed = false override fun setUp() { super.setUp() view = MdViewFactory.hybrid() + disposed = false + } + + override fun tearDown() { + try { + if (this::view.isInitialized && !disposed) Disposer.dispose(view) + } finally { + super.tearDown() + } } fun `test set stores source`() { @@ -199,6 +211,39 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(scrolls().isEmpty()) } + fun `test rerender disposes previous code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + view.set("plain text") + drainEdt() + + assertTrue(editor.isDisposed) + assertTrue(scrolls().isEmpty()) + } + + fun `test clear disposes code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + view.clear() + drainEdt() + + assertTrue(editor.isDisposed) + } + + fun `test dispose disposes code block editor`() { + view.set("```kotlin\nval value = 1\n```") + val editor = editors().single().getEditor(true)!! + + Disposer.dispose(view) + disposed = true + drainEdt() + + assertTrue(editor.isDisposed) + assertTrue(scrolls().isEmpty()) + } + fun `test applyStyle updates current and future blocks`() { val style = SessionEditorStyle.create(family = "Courier New", size = 21) @@ -248,4 +293,8 @@ class MdViewHybridTest : BasePlatformTestCase() { view.component.doLayout() scrolls().forEach { it.doLayout() } } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } } From cf0fd63f0f2c6086e9e768e3137c4f7e8782644f Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 19:19:07 -0400 Subject: [PATCH 056/201] fix(kilo-jetbrains): stabilize models settings pickers --- .../ai/kilocode/client/app/KiloAppService.kt | 6 +- .../client/session/ui/model/ModelPicker.kt | 45 ++++++-- .../session/ui/model/ModelPickerRenderer.kt | 19 +++- .../session/ui/model/ModelPickerRows.kt | 12 +- .../settings/models/ModelSettingPicker.kt | 20 +--- .../settings/models/ModelsSettingsState.kt | 11 ++ .../settings/models/ModelsSettingsUi.kt | 106 ++++++++++++------ .../session/ui/model/ModelPickerTest.kt | 88 +++++++++++++-- .../settings/models/ModelSettingPickerTest.kt | 19 ++++ .../models/ModelsSettingsStateTest.kt | 17 +++ 10 files changed, 263 insertions(+), 80 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelSettingPickerTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 6d423d05cff..5d2e9c45db1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -215,13 +215,13 @@ class KiloAppService internal constructor( } } - suspend fun updateConfig(patch: ConfigPatchDto): Boolean = try { + suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto? = try { val next = call { updateConfig(patch) } _state.value = next - true + next } catch (e: Exception) { LOG.warn("config update failed", e) - false + null } private fun setModelState(state: ModelStateDto) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index 97826e8a7b2..7ab82397889 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -62,10 +62,12 @@ class ModelPicker : PickerButton() { } var onSelect: (Item) -> Unit = {} + var onClear: () -> Unit = {} var favorites: () -> List = { emptyList() } var onFavoriteToggle: (Item) -> Unit = {} var allowEmpty: Boolean = false var emptyText: String = KiloBundle.message("settings.models.notSet") + var includeSmall: Boolean = false private var items: List = emptyList() private var selected: Item? = null @@ -77,7 +79,7 @@ class ModelPicker : PickerButton() { addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { - if (!isEnabled || items.isEmpty()) return + if (!isEnabled || (items.isEmpty() && !allowEmpty)) return showPopup() } }) @@ -107,9 +109,9 @@ class ModelPicker : PickerButton() { private fun refresh() { if (items.isEmpty()) { - isEnabled = false + isEnabled = allowEmpty text = if (allowEmpty) emptyText else " " - cursor = Cursor.getDefaultCursor() + cursor = if (allowEmpty) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() return } val display = selected?.display @@ -119,7 +121,7 @@ class ModelPicker : PickerButton() { } private fun showPopup() { - val rows = modelPickerRows(items, favorites(), "") + val rows = modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall) val model = CollectionListModel(rows) val list = JBList(model).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION @@ -148,7 +150,7 @@ class ModelPicker : PickerButton() { } fun sync(prefer: String? = activeKey(), at: Int? = null) { - val rows = modelPickerRows(items, favorites(), search.text) + val rows = modelPickerRows(items, favorites(), search.text, allowEmpty, emptyText, includeSmall) model.replaceAll(rows) val idx = at?.let { modelPickerIndex(rows, it) }?.takeIf { it >= 0 } ?: modelPickerIndex(rows, prefer).takeIf { it >= 0 } @@ -166,6 +168,22 @@ class ModelPicker : PickerButton() { popup.closeOk(null) } + fun clear() { + selected = null + refresh() + onClear() + popup.closeOk(null) + } + + fun activate(row: ModelPickerRow) { + val item = row.item + if (item == null) { + clear() + return + } + activate(item) + } + fun move(step: Int) { val size = model.size if (size <= 0) return @@ -175,8 +193,9 @@ class ModelPicker : PickerButton() { } fun toggle(row: ModelPickerRow) { + val item = row.item ?: return val idx = list.selectedIndex - onFavoriteToggle(row.item) + onFavoriteToggle(item) sync(at = idx) list.selectedIndex.takeIf { it >= 0 }?.let { repaintRow(list, it) } } @@ -197,7 +216,7 @@ class ModelPicker : PickerButton() { JComponent.WHEN_FOCUSED, ) search.textEditor.registerKeyboardAction( - { list.selectedValue?.item?.let(::activate) }, + { list.selectedValue?.let(::activate) }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED, ) @@ -212,7 +231,7 @@ class ModelPicker : PickerButton() { JComponent.WHEN_FOCUSED, ) list.registerKeyboardAction( - { list.selectedValue?.item?.let(::activate) }, + { list.selectedValue?.let(::activate) }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED, ) @@ -238,7 +257,7 @@ class ModelPicker : PickerButton() { e.consume() return } - activate(value.item) + activate(value) } }) ListUtil.installAutoSelectOnMouseMove(list) @@ -290,10 +309,14 @@ class ModelPicker : PickerButton() { } internal data class ModelPickerRow( - val item: ModelPicker.Item, + val item: ModelPicker.Item?, val section: String?, val favorite: Boolean, -) + val emptyText: String = "", +) { + val key: String? get() = item?.key + val isEmpty: Boolean get() = item == null +} private fun computeInitialPopupSize( list: JList, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index a6c004b46fc..b2cf4c26ca6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -124,22 +124,31 @@ internal class ModelPickerRenderer( sep.setHideLine(index == 0) top.isVisible = section != null - check.icon = if (value.item.key == active()) checked else empty + check.icon = if (value.key == active()) checked else empty title.clear() - val name = ModelText.parts(value.item) + val item = value.item + if (item == null) { + title.append(value.emptyText, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) + head.getComponent(1).isVisible = false + provider.isVisible = false + star.icon = EmptyIcon.ICON_16 + top.invalidate() + return this + } + val name = ModelText.parts(item) if (name.provider != null) { title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) } title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) - head.getComponent(1).isVisible = value.item.free + head.getComponent(1).isVisible = item.free provider.isVisible = value.favorite - provider.text = value.item.providerName + provider.text = item.providerName provider.foreground = weak provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - val fav = value.item.key in favorites() + val fav = item.key in favorites() star.icon = when { fav -> AllIcons.Nodes.Favorite selected -> AllIcons.Nodes.NotFavoriteOnHover diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRows.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRows.kt index 22a069cf787..4f3ac688771 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRows.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRows.kt @@ -7,9 +7,12 @@ internal fun modelPickerRows( items: List, favorites: List, query: String, + allowEmpty: Boolean = false, + emptyText: String = KiloBundle.message("settings.models.notSet"), + includeSmall: Boolean = false, ): List { val q = query.trim() - val all = items.filterNot(ModelText::small) + val all = if (includeSmall) items else items.filterNot(ModelText::small) val filtered = all.filter { ModelSearch.matches(q, it.display) || ModelSearch.matches(q, it.id) || ModelSearch.matches(q, it.providerName) } @@ -22,6 +25,9 @@ internal fun modelPickerRows( .toList() .sortedWith(compareBy>> { ModelText.providerSort(it.first) }) val out = mutableListOf() + if (allowEmpty && ModelSearch.matches(q, emptyText)) { + out += ModelPickerRow(null, null, favorite = false, emptyText = emptyText) + } if (q.isBlank()) { val byKey = all.associateBy { it.key } val fav = favorites.map { "${it.providerID}/${it.modelID}" }.mapNotNull(byKey::get) @@ -43,8 +49,8 @@ internal fun modelPickerRows( } internal fun modelPickerIndex(rows: List, key: String?): Int { - if (key == null) return -1 - return rows.indexOfFirst { it.item.key == key } + if (key == null) return rows.indexOfFirst { it.item == null } + return rows.indexOfFirst { it.item?.key == key } } internal fun modelPickerIndex(rows: List, index: Int): Int { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt index 21922c7f321..da0831be360 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelSettingPicker.kt @@ -2,39 +2,29 @@ package ai.kilocode.client.settings.models import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.model.ModelPicker -import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis -import com.intellij.ui.components.ActionLink -internal class ModelSettingPicker( - private val clear: () -> Unit, -) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()) { +internal class ModelSettingPicker : Stack(StackAxis.HORIZONTAL) { val picker = ModelPicker() - private val reset = ActionLink(KiloBundle.message("settings.models.reset")) { clear() } private var active = true + private var available = false init { picker.allowEmpty = true picker.emptyText = KiloBundle.message("settings.models.notSet") next(picker) - next(reset) - reset.isVisible = false } fun setItems(items: List, selected: String?) { + available = items.isNotEmpty() || picker.allowEmpty picker.setItems(items, selected) - if (!active) picker.isEnabled = false - } - - fun setClearVisible(value: Boolean) { - reset.isVisible = value + picker.isEnabled = active && available } override fun setEnabled(value: Boolean) { active = value super.setEnabled(value) - if (!value) picker.isEnabled = false - reset.isEnabled = value + picker.isEnabled = active && available } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt index b13fc9b8445..dc4b3cddc71 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt @@ -77,3 +77,14 @@ internal fun modelsStatus( } internal fun modelsLoginBannerVisible(ready: Boolean, authenticated: Boolean): Boolean = ready && !authenticated + +internal fun savedMatches(base: ModelsDraft, draft: ModelsDraft): Boolean { + if (base.model != draft.model) return false + if (base.small != draft.small) return false + if (base.subagent != draft.subagent) return false + if (base.variant != draft.variant) return false + for ((name, value) in draft.agents) { + if (base.agents[name] != value) return false + } + return true +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index b0e282013f3..77327d63bbe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -61,10 +61,15 @@ internal class ModelsSettingsUi( private val content = Stack.vertical() private val rows = SettingsRows() private val modes = SettingsRows() - private val defaults = ModelSettingPicker { update { copy(model = null) } } - private val small = ModelSettingPicker { update { copy(small = null) } } - private val subagent = ModelSettingPicker { update { copy(subagent = null, variant = null) } } + private val defaults = ModelSettingPicker() + private val small = ModelSettingPicker() + private val subagent = ModelSettingPicker() private val variant = ReasoningPicker() + private val variantRow = SettingsRow( + KiloBundle.message("settings.models.subagentVariant.title"), + KiloBundle.message("settings.models.subagentVariant.description"), + variant.align(HAlign.RIGHT, VAlign.CENTER), + ) private val pickers = mutableMapOf() private val jobs = mutableListOf() @@ -79,12 +84,17 @@ internal class ModelsSettingsUi( private var errors: List = emptyList() private var allItems: List = emptyList() private var saving = false + private var pending: ModelsDraft? = null init { status.foreground = UIUtil.getContextHelpForeground() defaults.picker.onSelect = { update { copy(model = it.key) } } + defaults.picker.onClear = { update { copy(model = null) } } small.picker.onSelect = { update { copy(small = it.key) } } + small.picker.onClear = { update { copy(small = null) } } + small.picker.includeSmall = true subagent.picker.onSelect = { item -> selectSubagent(item) } + subagent.picker.onClear = { update { copy(subagent = null, variant = null) } } variant.onSelect = { item -> update { copy(variant = item.id) } } listOf(defaults, small, subagent).forEach { picker -> picker.picker.favorites = { app.favorites.value } @@ -112,11 +122,7 @@ internal class ModelsSettingsUi( KiloBundle.message("settings.models.subagentModel.description"), subagent, )) - rows.row(SettingsRow( - KiloBundle.message("settings.models.subagentVariant.title"), - KiloBundle.message("settings.models.subagentVariant.description"), - variant.align(HAlign.RIGHT, VAlign.CENTER), - )) + rows.row(variantRow) content.next(rows) content.next(TitledSeparator(KiloBundle.message("settings.models.modeModels.title"))) content.next(JBLabel(KiloBundle.message("settings.models.modeModels.description")).apply { @@ -150,21 +156,28 @@ internal class ModelsSettingsUi( val next = draft val patch = patch(prev, next) if (patch.values.isEmpty() && patch.agents.isEmpty()) return - baseline = next + pending = next saving = true status.text = KiloBundle.message("settings.models.save.pending") status.foreground = UIUtil.getContextHelpForeground() sync() cs.launch { - val ok = app.updateConfig(patch) + val state = app.updateConfig(patch) withContext(edt) { - if (ok) { + if (state != null) { + appState = state + val base = modelsDraft(state.config, agents) + baseline = if (savedMatches(base, next)) base else next + draft = next + pending = null saving = false status.text = "" sync() return@withContext } baseline = prev + draft = next + pending = null saving = false status.text = KiloBundle.message("settings.models.save.failed") status.foreground = UiStyle.Colors.errorLabelForeground() @@ -179,14 +192,15 @@ internal class ModelsSettingsUi( appState = state if (state.status != KiloAppStatusDto.READY) { loading = false - loaded = false - providers = null - agents = emptyList() - errors = emptyList() + if (!loaded && providers == null) { + agents = emptyList() + errors = emptyList() + } + sync() + return } val base = modelsDraft(state.config, agents) - baseline = base - if (!saving) draft = base + acceptBase(base) sync() loadModels() } @@ -200,8 +214,7 @@ internal class ModelsSettingsUi( loaded = true loading = false val base = modelsDraft(appState.config, agents) - baseline = base - if (!saving) draft = base + acceptBase(base) sync() } @@ -268,10 +281,12 @@ internal class ModelsSettingsUi( saving = saving, ) val ready = state == ModelsStatus.READY || state == ModelsStatus.MODES_FAILED - banner.isVisible = modelsLoginBannerVisible( + val bannerVisible = modelsLoginBannerVisible( ready = appState.status == KiloAppStatusDto.READY, authenticated = appState.profile != null, ) + val structural = banner.isVisible != bannerVisible + banner.isVisible = bannerVisible if (!saving) { when (state) { ModelsStatus.UNAVAILABLE -> { @@ -301,18 +316,19 @@ internal class ModelsSettingsUi( } } } - status.isVisible = status.text.isNotBlank() + val statusVisible = status.text.isNotBlank() + var layout = structural || status.isVisible != statusVisible + status.isVisible = statusVisible defaults.setItems(allItems, draft.model) small.setItems(smallItems, draft.small) subagent.setItems(allItems, draft.subagent) - defaults.setClearVisible(draft.model != null) - small.setClearVisible(draft.small != null) - subagent.setClearVisible(draft.subagent != null) listOf(defaults, small, subagent).forEach { it.isEnabled = ready } - syncVariant(ready) - syncModes(ready) - revalidate() - repaint() + layout = syncVariant(ready) || layout + layout = syncModes(ready) || layout + if (layout) { + revalidate() + repaint() + } } private fun items(includeSmall: Boolean): List { @@ -329,27 +345,33 @@ internal class ModelsSettingsUi( } @RequiresEdt - private fun syncVariant(ready: Boolean) { + private fun syncVariant(ready: Boolean): Boolean { val item = allItems.firstOrNull { it.key == draft.subagent || it.id == draft.subagent } val valid = item?.variants.orEmpty() if (draft.variant != null && draft.variant !in valid) draft = draft.copy(variant = valid.firstOrNull()) if (draft.subagent != null && valid.isEmpty() && draft.variant != null) draft = draft.copy(variant = null) variant.setItems(valid.map { ReasoningPicker.Item(it, variantTitle(it)) }, draft.variant) variant.isEnabled = ready && valid.isNotEmpty() - variant.isVisible = valid.isNotEmpty() + val visible = valid.isNotEmpty() + val changed = variantRow.isVisible != visible + variantRow.isVisible = visible + variant.isVisible = visible + return changed } @RequiresEdt - private fun syncModes(ready: Boolean) { - val names = agents.map { it.name }.toSet() - if (names != pickers.keys) { + private fun syncModes(ready: Boolean): Boolean { + var layout = false + val names = agents.map { it.name } + if (names != pickers.keys.toList()) { modes.removeAll() pickers.clear() agents.forEach { agent -> - val picker = ModelSettingPicker { update { copy(agents = this.agents + (agent.name to null)) } } + val picker = ModelSettingPicker() picker.picker.favorites = { app.favorites.value } picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } picker.picker.onSelect = { item -> update { copy(agents = this.agents + (agent.name to item.key)) } } + picker.picker.onClear = { update { copy(agents = this.agents + (agent.name to null)) } } pickers[agent.name] = picker modes.row(SettingsRow( agent.displayName ?: title(agent.name), @@ -357,13 +379,14 @@ internal class ModelsSettingsUi( picker, )) } + layout = true } for ((name, picker) in pickers) { val value = draft.agents[name] picker.setItems(allItems, value) - picker.setClearVisible(value != null) picker.isEnabled = ready } + return layout } private fun selectSubagent(item: ModelPicker.Item) { @@ -398,6 +421,19 @@ internal class ModelsSettingsUi( sync() } + private fun acceptBase(base: ModelsDraft) { + val save = pending + if (save == null) { + baseline = base + if (!saving) draft = base + return + } + if (!savedMatches(base, save)) return + baseline = base + draft = save + pending = null + } + private fun checkEdt() { check(ApplicationManager.getApplication().isDispatchThread) { "Models settings UI updates must run on EDT" } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt index 7970e3976be..1d6980cbe8a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt @@ -35,10 +35,10 @@ class ModelPickerTest : BasePlatformTestCase() { ), listOf(ModelSelectionDto("openai", "gpt-4o")), "") assertEquals("Favorites", modelPickerSectionTitle(rows, 0)) - assertEquals("openai/gpt-4o", rows[0].item.key) + assertEquals("openai/gpt-4o", rows[0].key) assertEquals("Recommended", modelPickerSectionTitle(rows, 1)) - assertEquals("kilo/auto", rows[1].item.key) - assertEquals("anthropic/claude", rows[2].item.key) + assertEquals("kilo/auto", rows[1].key) + assertEquals("anthropic/claude", rows[2].key) } fun `test favorites are hidden while filtering`() { @@ -49,7 +49,7 @@ class ModelPickerTest : BasePlatformTestCase() { assertFalse(rows.indices.any { modelPickerSectionTitle(rows, it) == "Favorites" }) - assertEquals("openai/gpt-4o", rows.single().item.key) + assertEquals("openai/gpt-4o", rows.single().key) } fun `test favorites match provider qualified key when model ids collide`() { @@ -58,7 +58,7 @@ class ModelPickerTest : BasePlatformTestCase() { item("gpt", "Azure GPT", "azure", "Azure"), ), listOf(ModelSelectionDto("azure", "gpt")), "") - assertEquals("azure/gpt", rows[0].item.key) + assertEquals("azure/gpt", rows[0].key) assertEquals("Favorites", modelPickerSectionTitle(rows, 0)) } @@ -72,7 +72,7 @@ class ModelPickerTest : BasePlatformTestCase() { ModelSelectionDto("openai", "a"), ), "") - assertEquals(listOf("openai/b", "openai/a"), rows.take(2).map { it.item.key }) + assertEquals(listOf("openai/b", "openai/a"), rows.take(2).map { it.key }) assertTrue(rows.take(2).all { it.favorite }) } @@ -82,7 +82,7 @@ class ModelPickerTest : BasePlatformTestCase() { item("gpt", "GPT", "openai", "OpenAI"), ), emptyList(), "anth") - assertEquals(listOf("anthropic/claude-sonnet"), rows.map { it.item.key }) + assertEquals(listOf("anthropic/claude-sonnet"), rows.map { it.key }) } fun `test kilo provider group is first and other providers keep source order`() { @@ -111,7 +111,7 @@ class ModelPickerTest : BasePlatformTestCase() { ), listOf(ModelSelectionDto("openai", "b")), "") assertEquals(1, modelPickerIndex(rows, 1)) - assertEquals("openai/a", rows[modelPickerIndex(rows, 1)].item.key) + assertEquals("openai/a", rows[modelPickerIndex(rows, 1)].key) } fun `test index caps after favorite removal`() { @@ -123,6 +123,66 @@ class ModelPickerTest : BasePlatformTestCase() { assertEquals(-1, modelPickerIndex(emptyList(), 1)) } + fun `test allowEmpty inserts not set row`() { + val rows = modelPickerRows( + listOf(item("a", "A", "openai", "OpenAI")), + emptyList(), + "", + allowEmpty = true, + emptyText = "Not set", + ) + + assertTrue(rows.first().isEmpty) + assertEquals("Not set", rows.first().emptyText) + assertEquals(0, modelPickerIndex(rows, null)) + } + + fun `test allowEmpty filters not set row`() { + val rows = modelPickerRows( + listOf(item("a", "A", "openai", "OpenAI")), + emptyList(), + "not", + allowEmpty = true, + emptyText = "Not set", + ) + val missing = modelPickerRows( + listOf(item("a", "A", "openai", "OpenAI")), + emptyList(), + "openai", + allowEmpty = true, + emptyText = "Not set", + ) + + assertTrue(rows.first().isEmpty) + assertFalse(missing.any { it.isEmpty }) + } + + fun `test favorites only apply to real rows`() { + val rows = modelPickerRows( + listOf(item("a", "A", "openai", "OpenAI")), + listOf(ModelSelectionDto("openai", "a")), + "", + allowEmpty = true, + emptyText = "Not set", + ) + + assertFalse(rows.first().favorite) + assertTrue(rows.drop(1).any { it.favorite }) + } + + fun `test small rows are included only when requested`() { + val items = listOf( + item("auto-small", "Small", "kilo", "Kilo"), + item("auto", "Auto", "kilo", "Kilo"), + ) + + val default = modelPickerRows(items, emptyList(), "") + val small = modelPickerRows(items, emptyList(), "", includeSmall = true) + + assertEquals(listOf("kilo/auto"), default.mapNotNull { it.key }) + assertEquals(listOf("kilo/auto", "kilo/auto-small"), small.mapNotNull { it.key }.sorted()) + } + fun `test setItems preserves selected model when refreshed without default`() { val picker = ModelPicker() val rows = listOf( @@ -280,6 +340,18 @@ class ModelPickerTest : BasePlatformTestCase() { assertTrue(renderer.badgeVisible()) } + fun `test renderer hides favorite and free affordances for empty row`() { + val row = ModelPickerRow(null, null, false, "Not set") + val model = CollectionListModel(listOf(row)) + val renderer = ModelPickerRenderer(model, { null }, { setOf("kilo/auto") }) + val list = JBList(model) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + assertSame(EmptyIcon.ICON_16, renderer.starIcon()) + assertFalse(renderer.badgeVisible()) + } + private fun item( id: String, display: String, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelSettingPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelSettingPickerTest.kt new file mode 100644 index 00000000000..ac3fcaedd8f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelSettingPickerTest.kt @@ -0,0 +1,19 @@ +package ai.kilocode.client.settings.models + +import ai.kilocode.client.session.ui.model.ModelPicker +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class ModelSettingPickerTest : BasePlatformTestCase() { + + fun `test picker re-enables after ready state follows disabled state`() { + val picker = ModelSettingPicker() + val items = listOf(ModelPicker.Item("auto", "Auto", "kilo", "Kilo")) + + picker.setItems(emptyList(), null) + picker.isEnabled = false + picker.setItems(items, null) + picker.isEnabled = true + + assertTrue(picker.picker.isEnabled) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt index 8a1b8255dde..7f1da145943 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsStateTest.kt @@ -132,4 +132,21 @@ class ModelsSettingsStateTest { assertFalse(modelsLoginBannerVisible(ready = true, authenticated = true)) assertFalse(modelsLoginBannerVisible(ready = false, authenticated = false)) } + + @Test + fun `saved match requires saved top level values`() { + val draft = ModelsDraft(model = "kilo/gpt-5", small = "kilo/auto-small") + + assertTrue(savedMatches(draft, draft)) + assertFalse(savedMatches(draft.copy(model = "openai/gpt"), draft)) + } + + @Test + fun `saved match compares known pending agent values`() { + val draft = ModelsDraft(agents = mapOf("ask" to "kilo/gpt-5", "code" to null)) + val base = ModelsDraft(agents = mapOf("ask" to "kilo/gpt-5", "code" to null, "plan" to "openai/gpt")) + + assertTrue(savedMatches(base, draft)) + assertFalse(savedMatches(base.copy(agents = base.agents + ("ask" to "openai/gpt")), draft)) + } } From 4472e6d7693bfcdad0f39463f4db7fddb7e192d0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 2 Jun 2026 13:51:23 +0200 Subject: [PATCH 057/201] refactor: kilo compat for v1.14.46 --- .opencode-version | 2 +- bun.lock | 34 +- package.json | 2 +- packages/core/package.json | 2 +- .../src/util => core/src}/effect-zod.ts | 0 packages/core/src/flag/flag.ts | 11 + .../{opencode/src/util => core/src}/schema.ts | 0 packages/extensions/zed/extension.toml | 12 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- .../migration.sql | 2 +- packages/opencode/package.json | 6 +- packages/opencode/specs/effect/migration.md | 4 +- packages/opencode/specs/effect/schema.md | 6 +- .../specs/openapi-translation-cleanup.md | 204 ++ .../opencode/specs/v2/tui-command-shim.md | 67 + packages/opencode/src/acp/agent.ts | 130 +- packages/opencode/src/agent/agent.ts | 4 +- .../src/agent/subagent-permissions.ts | 33 + packages/opencode/src/auth/index.ts | 4 +- packages/opencode/src/cli/cmd/export.ts | 6 +- .../cli/cmd/tui/context/aggregate-failures.ts | 34 + .../opencode/src/cli/cmd/tui/context/sync.tsx | 30 +- packages/opencode/src/cli/cmd/tui/event.ts | 2 +- .../cmd/tui/feature-plugins/sidebar/lsp.tsx | 4 +- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 7 +- .../src/cli/cmd/tui/plugin/command-shim.ts | 109 + .../src/cli/cmd/tui/plugin/runtime.ts | 3 + packages/opencode/src/command/index.ts | 4 +- packages/opencode/src/config/agent.ts | 4 +- packages/opencode/src/config/command.ts | 4 +- packages/opencode/src/config/config.ts | 6 +- packages/opencode/src/config/console-state.ts | 4 +- packages/opencode/src/config/formatter.ts | 4 +- packages/opencode/src/config/layout.ts | 4 +- packages/opencode/src/config/lsp.ts | 4 +- packages/opencode/src/config/mcp.ts | 4 +- packages/opencode/src/config/model-id.ts | 4 +- packages/opencode/src/config/parse.ts | 2 +- packages/opencode/src/config/permission.ts | 4 +- packages/opencode/src/config/plugin.ts | 4 +- packages/opencode/src/config/provider.ts | 7 +- packages/opencode/src/config/reference.ts | 4 +- packages/opencode/src/config/server.ts | 4 +- packages/opencode/src/config/skills.ts | 4 +- packages/opencode/src/control-plane/schema.ts | 4 +- packages/opencode/src/control-plane/types.ts | 2 +- packages/opencode/src/file/index.ts | 4 +- packages/opencode/src/file/ripgrep.ts | 4 +- packages/opencode/src/format/index.ts | 4 +- packages/opencode/src/lsp/lsp.ts | 4 +- packages/opencode/src/markdown.d.ts | 4 + packages/opencode/src/mcp/index.ts | 52 +- packages/opencode/src/permission/index.ts | 4 +- packages/opencode/src/permission/schema.ts | 4 +- packages/opencode/src/project/project.ts | 4 +- packages/opencode/src/project/schema.ts | 4 +- packages/opencode/src/project/vcs.ts | 17 +- packages/opencode/src/provider/auth.ts | 4 +- .../opencode/src/provider/model-status.ts | 9 + packages/opencode/src/provider/models.ts | 3 +- packages/opencode/src/provider/provider.ts | 21 +- packages/opencode/src/provider/schema.ts | 4 +- packages/opencode/src/pty/index.ts | 4 +- packages/opencode/src/pty/schema.ts | 4 +- packages/opencode/src/pty/ticket.ts | 2 +- packages/opencode/src/question/index.ts | 4 +- packages/opencode/src/question/schema.ts | 4 +- .../server/routes/instance/httpapi/event.ts | 2 + .../routes/instance/httpapi/groups/config.ts | 5 +- .../instance/httpapi/groups/experimental.ts | 29 +- .../routes/instance/httpapi/groups/file.ts | 11 +- .../instance/httpapi/groups/instance.ts | 18 +- .../routes/instance/httpapi/groups/mcp.ts | 10 +- .../instance/httpapi/groups/permission.ts | 4 +- .../routes/instance/httpapi/groups/project.ts | 6 +- .../instance/httpapi/groups/provider.ts | 6 +- .../routes/instance/httpapi/groups/pty.ts | 19 +- .../routes/instance/httpapi/groups/query.ts | 8 + .../instance/httpapi/groups/question.ts | 5 +- .../routes/instance/httpapi/groups/session.ts | 47 +- .../routes/instance/httpapi/groups/sync.ts | 8 +- .../routes/instance/httpapi/groups/tui.ts | 15 +- .../instance/httpapi/groups/v2/message.ts | 55 +- .../instance/httpapi/groups/v2/session.ts | 84 +- .../instance/httpapi/groups/workspace.ts | 9 +- .../instance/httpapi/handlers/config.ts | 2 +- .../instance/httpapi/handlers/experimental.ts | 2 +- .../instance/httpapi/handlers/provider.ts | 2 +- .../instance/httpapi/handlers/v2/message.ts | 1 + .../instance/httpapi/handlers/v2/session.ts | 27 + .../instance/httpapi/middleware/error.ts | 26 - .../httpapi/middleware/workspace-routing.ts | 14 +- .../server/routes/instance/httpapi/public.ts | 61 +- packages/opencode/src/session/message-v2.ts | 32 +- packages/opencode/src/session/message.ts | 14 +- packages/opencode/src/session/prompt.ts | 6 +- packages/opencode/src/session/revert.ts | 4 +- packages/opencode/src/session/schema.ts | 4 +- packages/opencode/src/session/session.ts | 12 +- packages/opencode/src/session/status.ts | 4 +- packages/opencode/src/session/summary.ts | 5 +- packages/opencode/src/session/todo.ts | 4 +- packages/opencode/src/skill/index.ts | 24 +- .../src/skill/prompt/customize-opencode.md | 354 +++ packages/opencode/src/snapshot/index.ts | 15 +- packages/opencode/src/storage/storage.ts | 2 +- packages/opencode/src/sync/index.ts | 2 +- packages/opencode/src/sync/schema.ts | 4 +- packages/opencode/src/tool/read.ts | 4 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/schema.ts | 4 +- packages/opencode/src/tool/shell/prompt.ts | 2 +- packages/opencode/src/tool/task.ts | 37 +- .../opencode/src/util/named-schema-error.ts | 2 +- packages/opencode/src/v2/auth.ts | 2 +- packages/opencode/src/v2/event.ts | 2 +- packages/opencode/src/v2/model.ts | 5 +- packages/opencode/src/v2/session-event.ts | 16 +- packages/opencode/src/v2/session.ts | 2 +- .../test/acp/event-subscription.test.ts | 181 +- .../agent/plan-mode-subagent-bypass.test.ts | 141 + .../cli/cmd/tui/aggregate-failures.test.ts | 55 + .../test/cli/cmd/tui/sync-fixture.tsx | 120 + .../cmd/tui/sync-undefined-messages.test.tsx | 47 + .../opencode/test/cli/cmd/tui/sync.test.tsx | 121 +- packages/opencode/test/mcp/lifecycle.test.ts | 68 +- packages/opencode/test/preload.ts | 5 + .../test/provider/model-status.test.ts | 61 + .../test/server/httpapi-config.test.ts | 37 + .../test/server/httpapi-exercise/index.ts | 114 +- .../httpapi-promptasync-context.test.ts | 189 ++ .../test/server/httpapi-provider.test.ts | 185 ++ .../server/httpapi-query-schema-drift.test.ts | 282 ++ .../opencode/test/server/httpapi-sdk.test.ts | 10 +- .../test/server/httpapi-session.test.ts | 37 + .../opencode/test/server/httpapi-sync.test.ts | 2 +- .../server/negative-tokens-regression.test.ts | 97 + .../test/server/sdk-error-shape.test.ts | 74 + .../server/session-diff-missing-patch.test.ts | 81 + .../test/server/session-messages.test.ts | 25 +- .../test/session/schema-decoding.test.ts | 20 + .../test/session/snapshot-tool-race.test.ts | 2 +- .../storage/workspace-time-migration.test.ts | 43 + .../opencode/test/tool/parameters.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 15 +- .../opencode/test/util/effect-zod.test.ts | 2 +- packages/plugin/package.json | 2 +- packages/plugin/src/tui.ts | 43 + packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/sdk/js/src/client.ts | 2 + packages/sdk/js/src/error-interceptor.ts | 51 + packages/sdk/js/src/gen/types.gen.ts | 4 +- packages/sdk/js/src/v2/client.ts | 21 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 + packages/sdk/js/src/v2/gen/types.gen.ts | 24 +- packages/sdk/openapi.json | 2312 +++++++++-------- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- packages/ui/src/components/session-diff.ts | 3 +- packages/ui/src/components/session-review.tsx | 9 +- packages/ui/src/components/session-turn.tsx | 11 +- 163 files changed, 4730 insertions(+), 1799 deletions(-) rename packages/{opencode/src/util => core/src}/effect-zod.ts (100%) rename packages/{opencode/src/util => core/src}/schema.ts (100%) create mode 100644 packages/opencode/specs/openapi-translation-cleanup.md create mode 100644 packages/opencode/specs/v2/tui-command-shim.md create mode 100644 packages/opencode/src/agent/subagent-permissions.ts create mode 100644 packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts create mode 100644 packages/opencode/src/markdown.d.ts create mode 100644 packages/opencode/src/provider/model-status.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/query.ts create mode 100644 packages/opencode/src/skill/prompt/customize-opencode.md create mode 100644 packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts create mode 100644 packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts create mode 100644 packages/opencode/test/cli/cmd/tui/sync-fixture.tsx create mode 100644 packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx create mode 100644 packages/opencode/test/provider/model-status.test.ts create mode 100644 packages/opencode/test/server/httpapi-promptasync-context.test.ts create mode 100644 packages/opencode/test/server/httpapi-query-schema-drift.test.ts create mode 100644 packages/opencode/test/server/negative-tokens-regression.test.ts create mode 100644 packages/opencode/test/server/sdk-error-shape.test.ts create mode 100644 packages/opencode/test/server/session-diff-missing-patch.test.ts create mode 100644 packages/opencode/test/storage/workspace-time-migration.test.ts create mode 100644 packages/sdk/js/src/error-interceptor.ts diff --git a/.opencode-version b/.opencode-version index 5ceca955de6..dfbc2e65373 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.14.42 +v1.14.46 diff --git a/bun.lock b/bun.lock index 2d216eed605..9dcad8f126c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.42", + "version": "1.14.46", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.42", + "version": "1.14.46", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/package.json b/package.json index fb162e559d5..2c24470b0cf 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.3.18", + "version": "7.3.22", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 3875f8a167a..ddfaf9d4af0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.18", + "version": "7.3.22", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/core/src/effect-zod.ts similarity index 100% rename from packages/opencode/src/util/effect-zod.ts rename to packages/core/src/effect-zod.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index e58962aa3c4..a05485abfdb 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,13 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels where new experiments default to ON (unstable / internal users). +// Stable channels (`prod`, `latest`) stay opt-in. +const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) +function unstableDefault(key: string) { + return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel)) +} + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -48,6 +56,9 @@ export const Flag = { KILO_DISABLE_CLAUDE_CODE_PROMPT: KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT"), KILO_DISABLE_CLAUDE_CODE_SKILLS, KILO_DISABLE_EXTERNAL_SKILLS: truthy("KILO_DISABLE_EXTERNAL_SKILLS"), + // Default-on for dev/beta/local; opt-in for stable. Set + // KILO_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on. + KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"), KILO_FAKE_VCS: process.env["KILO_FAKE_VCS"], KILO_SERVER_PASSWORD: process.env["KILO_SERVER_PASSWORD"], KILO_SERVER_USERNAME: process.env["KILO_SERVER_USERNAME"], diff --git a/packages/opencode/src/util/schema.ts b/packages/core/src/schema.ts similarity index 100% rename from packages/opencode/src/util/schema.ts rename to packages/core/src/schema.ts diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index aba2476b2cc..6a2272ebf34 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "1.14.42" +version = "7.3.22" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index e884ada7709..6c852c38d35 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.18", + "version": "7.3.22", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 9d86bfe2122..65d8c39d16c 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.18", + "version": "7.3.22", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql index bdeed6bce18..c865526a88e 100644 --- a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql +++ b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql @@ -1 +1 @@ -ALTER TABLE `workspace` ADD `time_used` integer NOT NULL; \ No newline at end of file +ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0; diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8cb9da28933..c4ad3e1f58f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.18", + "version": "7.3.22", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -130,6 +130,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", @@ -190,8 +191,7 @@ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5", - "@opentui/keymap": "catalog:" + "zod-to-json-schema": "3.24.5" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 947eef5a156..01af9da6ce7 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -59,10 +59,10 @@ Rules: ## Schema → Zod interop -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: +When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: ```ts -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index e755457e614..e20605c3bc3 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims. Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors. Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@/util/effect-zod`. +schema via `@opencode-ai/core/effect-zod`. The long-term driver is `specs/effect/http-api.md` — once the HTTP server moves to `@effect/platform`, every Schema-first DTO can flow through @@ -97,7 +97,7 @@ creating a parallel schema source of truth. ## Escape hatches -The walker in `@/util/effect-zod` exposes two explicit escape hatches for +The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for cases the pure-Schema path cannot express. Each one stays in the codebase only as long as its upstream or local dependency requires it — inline comments document when each can be deleted. @@ -389,7 +389,7 @@ piecewise. ## Notes -- Use `@/util/effect-zod` for all Schema → Zod conversion. +- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md new file mode 100644 index 00000000000..55e4c7268d4 --- /dev/null +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -0,0 +1,204 @@ +# OpenAPI Translation Cleanup Plan + +## Goal + +Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface. + +The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation. + +## Current Culprit + +`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility. + +The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime. + +## Non-Negotiables + +- Do not break the generated JavaScript SDK without an explicit versioned migration plan. +- Runtime route schemas are the source of truth for accepted params, payloads, and responses. +- `/doc`, generated SDK types, and runtime validation must agree for every endpoint. +- Prefer endpoint or schema annotations over post-generation spec surgery. +- Remove one category of rewrite at a time, with focused compatibility checks. + +## PR Checklist + +Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started. + +Current combined PR scope: + +- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes. +- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs. + +### PR 1: Add OpenAPI/Runtime Query Drift Tests + +- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` Import `OpenApi.fromApi` and `PublicApi`. +- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`. +- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes. +- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema. +- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params. +- `[x]` Keep this part test-only. + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 2: Delete Spec-Only Workspace Query Injection + +- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- `[x]` Delete `InstanceQueryParameters`. +- `[x]` Delete the `isInstanceRoute` constant. +- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation. +- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`. +- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes. +- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only. + +Notes: + +- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params. +- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing. + +Expected code shape: + +```ts +for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`) +``` + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `[x]` `./packages/sdk/js/script/build.ts` from repo root. +- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers + +- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time. +- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`. +- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`. +- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet. + +Concrete first targets: + +- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. +- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter. +- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. + +Verification: + +- Focused HTTP tests for changed query fields. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK request param types before deleting each override. +- `bun typecheck` from `packages/opencode`. + +### PR 4: Move Path Parameter Patterns Into ID Schemas + +- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. +- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. +- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Delete one path override only after generated OpenAPI is unchanged for that param. + +Concrete first targets: + +- `sessionID` +- `messageID` +- `partID` +- `permissionID` +- `ptyID` + +Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated path param types and patterns. +- `bun typecheck` from `packages/opencode`. + +### PR 5: Replace Built-In Error Rewrites With Declared API Errors + +- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`. +- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there. +- Update handlers to fail with the declared API errors at the boundary. +- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible. +- Do this group by group, starting with one small route group. + +Concrete first targets: + +- `groups/config.ts` `PATCH /config` bad request. +- `groups/session.ts` endpoints that already translate domain not-found errors. +- `groups/file.ts` if any handler currently relies on built-in error shape. + +Verification: + +- Focused HTTP tests asserting response body shape for changed error paths. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect SDK error union diff. +- `bun typecheck` from `packages/opencode`. + +### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It + +- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`. +- Decide whether SDK should expose auth in generated operation metadata. +- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code. +- If removing it, update SDK generation expectations and docs in the same PR. + +Verification: + +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated client call signatures and error unions. +- Do not merge if auth churn changes normal SDK call ergonomics unintentionally. + +### PR 7: Tackle Component Shape Rewrites One At A Time + +- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`. +- For each rewrite, make a tiny PR that removes or narrows only that rewrite. +- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first. + +Concrete first targets: + +- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially. +- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source. +- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK type-name and optionality diffs. + +## Upstream Middleware Query Support + +Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation. + +Target in `effect-smol`: + +- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation. +- Make runtime request decoding include middleware query schemas. +- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware. + +Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`. + +## Suggested PR Order + +1. Add drift detection tests only. +2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas. +3. Convert query type overrides into route/schema-level helpers where possible. +4. Convert path parameter overrides into schema annotations or upstream fixes. +5. Replace built-in error response rewrites with explicit declared API errors by route group. +6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable. + +## Verification Checklist Per PR + +- Focused HTTP tests for changed routes. +- OpenAPI drift tests. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK diff for public API churn. +- `bun typecheck` from `packages/opencode`. diff --git a/packages/opencode/specs/v2/tui-command-shim.md b/packages/opencode/specs/v2/tui-command-shim.md new file mode 100644 index 00000000000..5afade2a966 --- /dev/null +++ b/packages/opencode/specs/v2/tui-command-shim.md @@ -0,0 +1,67 @@ +# TUI Command Shim Removal + +Problem: + +- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization +- v2 should expose only the keymap command API +- tests and fixtures should not encode legacy command behavior as expected behavior + +## Remove Public Types + +In `packages/plugin/src/tui.ts`, remove: + +- `TuiCommand` +- `TuiCommandApi` +- `TuiPluginApi.command` + +Keep `api.keymap` as the only TUI command registration and execution surface. + +## Remove Runtime Shim + +Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`. + +In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `createTuiApi(...)` + +In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `pluginApi(...)` + +## Migration Target + +Plugin authors should replace old calls with keymap calls: + +```ts +api.keymap.registerLayer({ + commands: [ + { + name: "plugin.command", + title: "Plugin Command", + namespace: "palette", + slashName: "plugin", + run() { + api.ui.dialog.clear() + }, + }, + ], + bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }], +}) +``` + +Direct replacements: + +- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })` +- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)` +- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")` +- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure + +## Verification + +After removal, run from package directories: + +- `bun typecheck` in `packages/plugin` +- `bun typecheck` in `packages/opencode` +- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index cfca3a92703..7d3e13a93e8 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -347,33 +347,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -413,10 +387,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((error) => { @@ -860,33 +831,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -926,10 +871,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((err) => { @@ -1655,6 +1597,70 @@ function toLocations(toolName: string, input: Record): { path: stri } } +function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { + if (part.state.status !== "completed") return [] + + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + content.push(...imageContents(part.state.attachments ?? [])) + return content +} + +function completedToolRawOutput(part: ToolPart) { + if (part.state.status !== "completed") return {} + return { + output: part.state.output, + metadata: part.state.metadata, + ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), + } +} + +function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { + return attachments.flatMap((attachment): ToolCallContent[] => { + const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? attachment.mime + if (!mime.startsWith("image/")) return [] + const data = match?.[2] + if (data === undefined) return [] + return [ + { + type: "content" as const, + content: { + type: "image" as const, + mimeType: mime, + data, + }, + }, + ] + }) +} + async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { const sdk = config.sdk const configured = config.defaultModel diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 3a7ffc2e3f9..bdf9fcf3452 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -24,8 +24,8 @@ import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" -import { zod } from "@/util/effect-zod" -import { withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" type ReferenceEntry = NonNullable[string] type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts new file mode 100644 index 00000000000..1174ec31ade --- /dev/null +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -0,0 +1,33 @@ +import type { Permission } from "../permission" +import type { Agent } from "./agent" + +/** + * Build the `permission` ruleset for a subagent's session when it's spawned + * via the task tool. Combines: + * + * 1. The parent **agent's** deny rules — Plan Mode and other agent-level + * restrictions live on the agent ruleset, not on the session, so a + * subagent that only inherited the parent SESSION's permission would + * silently bypass them. (#26514) + * 2. The parent **session's** deny rules and external_directory rules — + * same forwarding the original code already did. + * 3. Default `todowrite` and `task` denies if the subagent's own ruleset + * doesn't already permit them. + */ +export function deriveSubagentSessionPermission(input: { + parentSessionPermission: Permission.Ruleset + parentAgent: Agent.Info | undefined + subagent: Agent.Info +}): Permission.Ruleset { + const canTask = input.subagent.permission.some((rule) => rule.permission === "task") + const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") + const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? [] + return [ + ...parentAgentDenies, + ...input.parentSessionPermission.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), + ...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), + ] +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 0445e91839a..94f8e7537c3 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index bf73ce941e4..9eb1faffea7 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,11 +23,11 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) { +function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) { return diffs?.map((item, i) => ({ ...item, - file: redact(`${kind}-file`, String(i), item.file), - patch: redact(`${kind}-patch`, String(i), item.patch), + file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file), + patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts new file mode 100644 index 00000000000..63b3fb44876 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts @@ -0,0 +1,34 @@ +/** + * Aggregate Promise.allSettled results into a single Error that names every + * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap + * boundaries so a single 4xx doesn't drown its parallel siblings as + * unhandled rejections — every failure surfaces in one labeled message. + */ +export type LabeledSettled = { + name: string + result: PromiseSettledResult +} + +export function aggregateFailures(labeled: LabeledSettled[]): Error | null { + const failed = labeled.filter( + (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected", + ) + if (failed.length === 0) return null + + const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") + const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` + const err = new Error(summary) + err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } + return err +} + +function reasonMessage(reason: unknown): string { + if (reason instanceof Error) return reason.message + if (typeof reason === "string") return reason + if (reason && typeof reason === "object") { + const obj = reason as { message?: unknown; name?: unknown } + if (typeof obj.message === "string") return obj.message + if (typeof obj.name === "string") return obj.name + } + return String(reason) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index ef11922eca9..7ade7c71bb9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" +import { aggregateFailures } from "./aggregate-failures" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -391,16 +392,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const blockingRequests: Promise[] = [ - providersPromise, - providerListPromise, - agentsPromise, - configPromise, - projectPromise, - ...(args.continue ? [sessionListPromise] : []), + const blockingRequests: { name: string; promise: Promise }[] = [ + { name: "config.providers", promise: providersPromise }, + { name: "provider.list", promise: providerListPromise }, + { name: "app.agents", promise: agentsPromise }, + { name: "config.get", promise: configPromise }, + { name: "project.sync", promise: projectPromise }, + ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ] - await Promise.all(blockingRequests) + await Promise.allSettled(blockingRequests.map((r) => r.promise)) + .then((settled) => { + // Surface every failed endpoint in one labeled message instead of + // letting the first rejection drown its siblings as unhandled + // rejections. + const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] }))) + if (failure) throw failure + }) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) @@ -526,10 +534,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + const infos: (typeof draft.message)[string] = [] + for (const message of messages.data ?? []) { + infos.push(message.info) draft.part[message.info.id] = message.parts } + draft.message[sessionID] = infos draft.session_diff[sessionID] = diff.data ?? [] }), ) diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index fbe5ce7f9ff..bebb1fc6aa6 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index bbf3802bbbe..cd7d4a1ec72 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -22,9 +22,7 @@ function View(props: { api: TuiPluginApi }) {
- - {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} - + {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} {(item) => ( diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 76e464fc841..ba67a1b6cd2 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Keymap from "../keymap" +import { createCommandShim } from "./command-shim" type RouteEntry = { key: symbol @@ -147,7 +148,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.session.length }, diff(sessionID) { - return sync.data.session_diff[sessionID] ?? [] + return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => + item.file === undefined ? [] : [{ ...item, file: item.file }], + ) }, todo(sessionID) { return sync.data.todo[sessionID] ?? [] @@ -200,6 +203,8 @@ export function createTuiApi(input: Input): TuiPluginApi { } return { app: appApi(), + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds), keys: { formatSequence(parts) { return Keymap.formatKeySequence(parts, input.tuiConfig) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts new file mode 100644 index 00000000000..734cb772f04 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts @@ -0,0 +1,109 @@ +// Legacy `api.command` bridge for v1 plugins; remove in v2. +import type { TuiCommand, TuiPluginApi } from "@kilocode/plugin/tui" +import { TuiKeybind } from "../config/keybind" +import type { DialogContext } from "../ui/dialog" + +const COMMAND_PALETTE_SHOW = "command.palette.show" +const warned = new Set() + +type Warn = (api: string, replacement: string) => void +type LegacyDialog = TuiPluginApi["ui"]["dialog"] +type CommandShimDialog = DialogContext | LegacyDialog +type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"] + +function warnCommandShim(api: string, replacement: string) { + // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2. + console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement }) +} + +function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog { + if (!("stack" in dialog)) return dialog + return { + replace(render, onClose) { + dialog.replace(render, onClose) + }, + clear() { + dialog.clear() + }, + setSize(size) { + dialog.setSize(size) + }, + get size() { + return dialog.size + }, + get depth() { + return dialog.stack.length + }, + get open() { + return dialog.stack.length > 0 + }, + } +} + +function warnOnce(api: string, replacement: string, warn: Warn) { + if (warned.has(api)) return + warned.add(api) + warn(api, replacement) +} + +function toCommand(item: TuiCommand, dialog: LegacyDialog) { + return { + namespace: "palette", + name: item.value, + title: item.title, + desc: item.description, + category: item.category, + suggested: item.suggested, + hidden: item.hidden, + enabled: item.enabled, + slashName: item.slash?.name, + slashAliases: item.slash?.aliases, + run() { + return item.onSelect?.(dialog) + }, + } +} + +function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) { + return commands.flatMap((item) => + item.keybind + ? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + ? keybinds + .get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + .map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title })) + : [ + { + key: item.keybind, + cmd: item.value, + desc: item.title, + }, + ] + : [], + ) +} + +export function createCommandShim( + keymap: TuiPluginApi["keymap"], + dialog: CommandShimDialog, + keybinds: LegacyKeybinds, +): TuiPluginApi["command"] { + const shimDialog = createCommandShimDialog(dialog) + return { + register(cb) { + warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim) + const commands = cb() + return keymap.registerLayer({ + commands: commands.map((item) => toCommand(item, shimDialog)), + bindings: toBindings(commands, keybinds), + }) + }, + trigger(value) { + warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim) + keymap.dispatchCommand(value) + }, + show() { + warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim) + keymap.dispatchCommand(COMMAND_PALETTE_SHOW) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index e430d11e53c..3d4f998c30f 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +import { createCommandShim } from "./command-shim" ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) @@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, keymap, route, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 140d2b8a7a6..e26c4068b1d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index e72f6587284..94c8d8fe008 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent" import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 4d0fec6872f..c611f3c198d 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -5,8 +5,8 @@ import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 88962161c54..e89f28236af 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -22,8 +22,8 @@ import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath } from "../project/instance-context" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -307,7 +307,7 @@ export const Info = Schema.Struct({ })), ) -// Uses the shared `DeepMutable` from `@/util/schema`. See the definition +// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from // effect-smol (the upstream version collapses `unknown` to `{}`). export type Info = DeepMutable> & { diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 0d4f20df917..485e334167a 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 8c1f09a2471..222a750057c 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,8 +1,8 @@ export * as ConfigFormatter from "./formatter" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Entry = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts index 49c34b66398..a5299ea9557 100644 --- a/packages/opencode/src/config/layout.ts +++ b/packages/opencode/src/config/layout.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Layout = Schema.Literals(["auto", "stretch"]) .annotate({ identifier: "LayoutConfig" }) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 1cf93177e41..accfbee3b2f 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,8 +1,8 @@ export * as ConfigLSP from "./lsp" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as LSPServer from "../lsp/server" export const Disabled = Schema.Struct({ diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index fc31ba356fa..bb4fd88f04b 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 3ad9e035ce6..26fa2e0b346 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" // The original Zod schema carried an external $ref pointing at the models.dev // JSON schema. That external reference is not a named SDK component — it is a diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index 9351047894a..f964ed4e150 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -3,7 +3,7 @@ export * as ConfigParse from "./parse" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" import z from "zod" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { InvalidError, JsonError } from "./error" type ZodSchema = z.ZodType diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 5326b007bd9..8c5f8549967 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,7 +1,7 @@ export * as ConfigPermission from "./permission" import { Schema, SchemaGetter } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Action = Schema.Literals(["ask", "allow", "deny"]) .annotate({ identifier: "PermissionActionConfig" }) diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 9667dbb59ab..b1e3ec6f42e 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import path from "path" export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 7821bca5a93..af9aac69640 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), @@ -49,7 +50,7 @@ export const Model = Schema.Struct({ }), ), experimental: Schema.optional(Schema.Boolean), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(ModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index eea3d998c1e..36a8faff7ed 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,8 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const Git = Schema.Struct({ repository: Schema.String.annotate({ diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 3f136982694..159ba0ce5a9 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Server = Schema.Struct({ port: Schema.optional(PositiveInt).annotate({ diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f29d854f50a..f707e922eee 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ paths: Schema.optional(Schema.Array(Schema.String)).annotate({ diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 5a0850a2495..dd4c325490f 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( Schema.brand("WorkspaceID"), diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ba5a778fac6..e78d728e040 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,7 +1,7 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4dd6a3ae7a6..52f2b8486d4 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -14,8 +14,8 @@ import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ path: Schema.String, diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 27fd5f2323d..8459dd9ac14 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index a61eb7be29a..c9ab433f118 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -7,8 +7,8 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import * as Formatter from "./formatter" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "format" }) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 2f62cadc598..2f6f5099001 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod, ZodOverride } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) diff --git a/packages/opencode/src/markdown.d.ts b/packages/opencode/src/markdown.d.ts new file mode 100644 index 00000000000..eb3e3b92d66 --- /dev/null +++ b/packages/opencode/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fe718023885..ed74c648ad5 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -30,12 +31,21 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod as effectZod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod as effectZod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 +const TolerantToolSchema = ToolSchema.extend({ + outputSchema: z.unknown().optional(), +}) + +const TolerantListToolsResultSchema = z.looseObject({ + tools: z.array(TolerantToolSchema), + nextCursor: z.string().optional(), +}) + export const Resource = Schema.Struct({ name: Schema.String, uri: Schema.String, @@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) { log.warn("invalid remote mcp url", { key }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + +function listTools(key: string, client: MCPClient, timeout: number) { + return Effect.tryPromise({ + try: () => client.listTools(undefined, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => result.tools), + Effect.catch((error) => { + if (!isOutputSchemaValidationError(error)) return Effect.fail(error) + + log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) + return Effect.tryPromise({ + try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => + result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ) + }), + ) +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number } function defs(key: string, client: MCPClient, timeout?: number) { - return Effect.tryPromise({ - try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((result) => result.tools), + return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe( Effect.catch((err) => { log.error("failed to get tools from client", { key, error: err }) return Effect.succeed(undefined) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d93670709e1..f4bd2e2cc1a 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index e8bdb2ea20e..725030935db 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 38f6543c90f..7cb765ba0dd 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index 7708b8de1ee..c6cff94fde6 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 02173453dbf..092444c444c 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod, zodObject } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, zodObject } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 @@ -230,9 +230,12 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, - patch: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + // Mirrors Snapshot.FileDiff (see #26574). The current producer always + // populates patch, but loosening matches the sibling schema so a + // future code path that omits it can't crash /instance/vcs/diff. + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) @@ -241,8 +244,8 @@ export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ file: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "VcsFileStatus" }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 22f21a9fde5..e19eb8792b3 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,9 +1,9 @@ import type { AuthOAuthResult, Hooks } from "@kilocode/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" diff --git a/packages/opencode/src/provider/model-status.ts b/packages/opencode/src/provider/model-status.ts new file mode 100644 index 00000000000..468b59ce39d --- /dev/null +++ b/packages/opencode/src/provider/model-status.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" + +export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) +export type CatalogModelStatus = typeof CatalogModelStatus.Type + +export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +export type ModelStatus = typeof ModelStatus.Type + +export * as ProviderModelStatus from "./model-status" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 39dd77aae39..d0507ff38fb 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" +import { CatalogModelStatus } from "./model-status" const Cost = Schema.Struct({ input: Schema.Finite, @@ -71,7 +72,7 @@ export const Model = Schema.Struct({ ), }), ), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(CatalogModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c6c9ecaf9f4..ecf34eba6b2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" @@ -24,10 +24,11 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" +import { ModelStatus } from "./model-status" const log = Log.create({ service: "provider" }) @@ -897,7 +898,7 @@ export const Model = Schema.Struct({ capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, @@ -935,6 +936,16 @@ export const ConfigProvidersResult = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ConfigProvidersResult = Types.DeepMutable> +export function toPublicInfo(provider: Info): Info { + return JSON.parse( + JSON.stringify(provider, (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }), + ) +} + export function defaultModelIDs }>(providers: Record) { return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) } @@ -1152,7 +1163,7 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) + const next = await models(toPublicInfo(provider), { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, @@ -1299,7 +1310,7 @@ const layer: Layer.Layer< const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - database[plugin.auth!.provider], + toPublicInfo(database[plugin.auth!.provider]), ), ) const opts = options ?? {} diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index ea3cac34249..757b70f3ff1 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 9ec46ebac72..806ee424c25 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,8 +10,8 @@ import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "pty" }) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 6b4d779f261..0f1d6996df5 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index b5e5747c513..0978e520837 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -3,7 +3,7 @@ export * as PtyTicket from "./ticket" import { WorkspaceID } from "@/control-plane/schema" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { PtyID } from "@/pty/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" const DEFAULT_TTL = Duration.seconds(60) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d52f353da98..c041462ad40 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -3,9 +3,9 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { QuestionID } from "./schema" const log = Log.create({ service: "question" }) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 7dade9cdfa9..c18eca3e23e 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class QuestionID extends Newtype()( "QuestionID", diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index a5c328ac0e3..8113c76f51d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import { HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" +import { WorkspaceRoutingQuery } from "./middleware/workspace-routing" const log = Log.create({ service: "server" }) @@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add( HttpApiGroup.make("event") .add( HttpApiEndpoint.get("subscribe", EventPaths.event, { + query: WorkspaceRoutingQuery, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index fa77785a9bc..a86845beff3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/config" @@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config") HttpApiGroup.make("config") .add( HttpApiEndpoint.get("get", root, { + query: WorkspaceRoutingQuery, success: described(Config.Info, "Get config info"), }).annotateMerge( OpenApi.annotations({ @@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.patch("update", root, { + query: WorkspaceRoutingQuery, payload: Config.Info, success: described(Config.Info, "Successfully updated config"), error: HttpApiError.BadRequest, @@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.get("providers", `${root}/providers`, { + query: WorkspaceRoutingQuery, success: described(Provider.ConfigProvidersResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 3488d2616c3..99a8a21a9e1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -3,13 +3,18 @@ import { MCP } from "@/mcp" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { Worktree } from "@/worktree" -import { NonNegativeInt } from "@/util/schema" -import { Schema, SchemaGetter } from "effect" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" +import { QueryBoolean } from "./query" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), @@ -43,19 +48,14 @@ const ToolListItem = Schema.Struct({ }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) export const ToolListQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, provider: ProviderID, model: ModelID, }) -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) const WorktreeList = Schema.Array(Schema.String) export const SessionListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), cursor: Schema.optional(Schema.NumberFromString), @@ -81,6 +81,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiGroup.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { + query: WorkspaceRoutingQuery, success: described(ConsoleStateResponse, "Active Console provider metadata"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -91,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { + query: WorkspaceRoutingQuery, success: described(ConsoleOrgList, "Switchable Console orgs"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -101,6 +103,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { + query: WorkspaceRoutingQuery, payload: ConsoleSwitchPayload, success: described(Schema.Boolean, "Switch success"), error: HttpApiError.BadRequest, @@ -124,6 +127,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { + query: WorkspaceRoutingQuery, success: described(ToolIDs, "Tool IDs"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -135,6 +139,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, success: described(WorktreeList, "List of worktree directories"), }).annotateMerge( OpenApi.annotations({ @@ -144,6 +149,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Schema.optional(Worktree.CreateInput), success: described(Worktree.Info, "Worktree created"), error: HttpApiError.BadRequest, @@ -155,6 +161,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Worktree.RemoveInput, success: described(Schema.Boolean, "Worktree removed"), error: HttpApiError.BadRequest, @@ -166,6 +173,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { + query: WorkspaceRoutingQuery, payload: Worktree.ResetInput, success: described(Schema.Boolean, "Worktree reset"), error: HttpApiError.BadRequest, @@ -188,6 +196,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383e..c636e583d7b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -5,18 +5,25 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, path: Schema.String, }) export const FindTextQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, pattern: Schema.String, }) export const FindFileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -26,6 +33,7 @@ export const FindFileQuery = Schema.Struct({ }) export const FindSymbolQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, }) @@ -93,6 +101,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.get("status", FilePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index f2b0504a05f..ea8db35035d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -8,7 +8,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -20,6 +24,7 @@ const PathInfo = Schema.Struct({ }).annotate({ identifier: "Path" }) export const VcsDiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, mode: Vcs.Mode, }) @@ -54,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance") HttpApiGroup.make("instance") .add( HttpApiEndpoint.post("dispose", InstancePaths.dispose, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Instance disposed"), }).annotateMerge( OpenApi.annotations({ @@ -63,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("path", InstancePaths.path, { + query: WorkspaceRoutingQuery, success: PathInfo, }).annotateMerge( OpenApi.annotations({ @@ -73,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcs", InstancePaths.vcs, { + query: WorkspaceRoutingQuery, success: described(Vcs.Info, "VCS info"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Vcs.FileStatus), "VCS status"), }).annotateMerge( OpenApi.annotations({ @@ -102,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + query: WorkspaceRoutingQuery, success: described( Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), "Raw VCS diff", @@ -114,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + query: WorkspaceRoutingQuery, payload: Vcs.ApplyInput, success: described(Vcs.ApplyResult, "VCS patch applied"), error: ApiVcsApplyError, @@ -125,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("command", InstancePaths.command, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( OpenApi.annotations({ @@ -134,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("agent", InstancePaths.agent, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Agent.Info), "List of agents"), }).annotateMerge( OpenApi.annotations({ @@ -143,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("skill", InstancePaths.skill, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Skill.Info), "List of skills"), }).annotateMerge( OpenApi.annotations({ @@ -152,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("lsp", InstancePaths.lsp, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(LSP.Status), "LSP server status"), }).annotateMerge( OpenApi.annotations({ @@ -161,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("formatter", InstancePaths.formatter, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Format.Status), "Formatter status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index b30714c196a..c7ed4a9b95d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" export const AddPayload = Schema.Struct({ @@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiGroup.make("mcp") .add( HttpApiEndpoint.get("status", McpPaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), }).annotateMerge( OpenApi.annotations({ @@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp") }), ), HttpApiEndpoint.post("add", McpPaths.status, { + query: WorkspaceRoutingQuery, payload: AddPayload, success: described(StatusMap, "MCP server added successfully"), error: HttpApiError.BadRequest, @@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthStartResponse, "OAuth flow started"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, payload: AuthCallbackPayload, success: described(MCP.Status, "OAuth authentication completed"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(MCP.Status, "OAuth authentication completed"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthRemoveResponse, "OAuth credentials removed"), error: HttpApiError.NotFound, }).annotateMerge( @@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server connected successfully"), }).annotateMerge( OpenApi.annotations({ @@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server disconnected successfully"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 22c4d6f6d32..5326596d399 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/permission" @@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission") HttpApiGroup.make("permission") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Permission.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: PermissionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 1a2084547d0..f95199eb011 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/project" @@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project") HttpApiGroup.make("project") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Project.Info), "List of projects"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.get("current", `${root}/current`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Current project information"), }).annotateMerge( OpenApi.annotations({ @@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.post("initGit", `${root}/git/init`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Project information after git initialization"), }).annotateMerge( OpenApi.annotations({ @@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project") ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { params: { projectID: ProjectID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 4a9bbffc549..49792898df3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/provider" @@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider") HttpApiGroup.make("provider") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Provider.ListResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ @@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider") }), ), HttpApiEndpoint.get("auth", `${root}/auth`, { + query: WorkspaceRoutingQuery, success: described(ProviderAuth.Methods, "Provider auth methods"), }).annotateMerge( OpenApi.annotations({ @@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.AuthorizeInput, success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), error: HttpApiError.BadRequest, @@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.CallbackInput, success: described(Schema.Boolean, "OAuth callback processed successfully"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index ad513e0ad41..1391d2a919a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -5,13 +5,20 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" export const Params = Schema.Struct({ ptyID: PtyID }) -export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const CursorQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + cursor: Schema.optional(Schema.String), +}) export const ShellItem = Schema.Struct({ path: Schema.String, name: Schema.String, @@ -34,6 +41,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiGroup.make("pty") .add( HttpApiEndpoint.get("shells", PtyPaths.shells, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(ShellItem), "List of shells"), }).annotateMerge( OpenApi.annotations({ @@ -43,6 +51,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.get("list", PtyPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Pty.Info), "List of sessions"), }).annotateMerge( OpenApi.annotations({ @@ -52,6 +61,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.post("create", PtyPaths.create, { + query: WorkspaceRoutingQuery, payload: Pty.CreateInput, success: described(Pty.Info, "Created session"), error: HttpApiError.BadRequest, @@ -64,6 +74,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.get("get", PtyPaths.get, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Pty.Info, "Session info"), error: ApiNotFoundError, }).annotateMerge( @@ -75,6 +86,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.put("update", PtyPaths.update, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -87,6 +99,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.delete("remove", PtyPaths.remove, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session removed"), error: ApiNotFoundError, }).annotateMerge( @@ -98,6 +111,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), error: [HttpApiError.Forbidden, ApiNotFoundError], }).annotateMerge( @@ -126,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( .add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Connected session"), error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts new file mode 100644 index 00000000000..d5b10d18003 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -0,0 +1,8 @@ +import { Schema, SchemaGetter } from "effect" + +export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index de2d4fca8ec..35cd3314b54 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/question" @@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question") HttpApiGroup.make("question") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Question.Request), "List of pending questions"), }).annotateMerge( OpenApi.annotations({ @@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Question answered successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Question rejected successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 967cc802062..ea68e76cafd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,23 +10,22 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" -import { Schema, SchemaGetter, Struct } from "effect" +import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" +import { QueryBoolean } from "./query" const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) export const ListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), @@ -34,8 +33,12 @@ export const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const DiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), +}) export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) @@ -112,6 +115,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.get("status", SessionPaths.status, { + query: WorkspaceRoutingQuery, success: described(StatusMap, "Get session status"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -123,6 +127,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Get session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -134,6 +139,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("children", SessionPaths.children, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Session.Info), "List of children"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -145,6 +151,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("todo", SessionPaths.todo, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Todo.Info), "Todo list"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -179,6 +186,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("message", SessionPaths.message, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(MessageV2.WithParts, "Message"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -189,6 +197,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.post("create", SessionPaths.create, { + query: WorkspaceRoutingQuery, payload: [HttpApiSchema.NoContent, Session.CreateInput], success: described(Session.Info, "Successfully created session"), error: HttpApiError.BadRequest, @@ -201,6 +210,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("remove", SessionPaths.remove, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -212,6 +222,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("update", SessionPaths.update, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Session.Info, "Successfully updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -224,6 +235,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ForkPayload, success: described(Session.Info, "200"), error: ApiNotFoundError, @@ -236,6 +248,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("abort", SessionPaths.abort, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Aborted session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -247,6 +260,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("init", SessionPaths.init, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: InitPayload, success: described(Schema.Boolean, "200"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -260,6 +274,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully shared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -271,6 +286,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully unshared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -282,6 +298,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("summarize", SessionPaths.summarize, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: SummarizePayload, success: described(Schema.Boolean, "Summarized session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -294,6 +311,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("prompt", SessionPaths.prompt, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -306,6 +324,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(HttpApiSchema.NoContent, "Prompt accepted"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -319,6 +338,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("command", SessionPaths.command, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -331,6 +351,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("shell", SessionPaths.shell, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ShellPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -343,6 +364,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("revert", SessionPaths.revert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: RevertPayload, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -356,6 +378,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -367,6 +390,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { params: { sessionID: SessionID, permissionID: PermissionID }, + query: WorkspaceRoutingQuery, payload: PermissionResponsePayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -380,6 +404,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -392,6 +417,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -402,6 +428,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, payload: MessageV2.Part, success: described(MessageV2.Part, "Successfully updated part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 442e6565547..38a93240eb7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,10 +1,10 @@ -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/sync" @@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync") HttpApiGroup.make("sync") .add( HttpApiEndpoint.post("start", SyncPaths.start, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Workspace sync started"), }).annotateMerge( OpenApi.annotations({ @@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("replay", SyncPaths.replay, { + query: WorkspaceRoutingQuery, payload: ReplayPayload, success: described(ReplayResponse, "Replayed sync events"), error: HttpApiError.BadRequest, @@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("steal", SyncPaths.steal, { + query: WorkspaceRoutingQuery, payload: SessionPayload, success: described(SessionPayload, "Session stolen into workspace"), error: HttpApiError.BadRequest, @@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("history", SyncPaths.history, { + query: WorkspaceRoutingQuery, payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 3676fc1964c..3cf3de5b8eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -54,6 +54,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiGroup.make("tui") .add( HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + query: WorkspaceRoutingQuery, payload: TuiEvent.PromptAppend.properties, success: described(Schema.Boolean, "Prompt processed successfully"), error: HttpApiError.BadRequest, @@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Help dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Theme dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openModels", TuiPaths.openModels, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Model dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt submitted successfully"), }).annotateMerge( OpenApi.annotations({ @@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt cleared successfully"), }).annotateMerge( OpenApi.annotations({ @@ -119,6 +126,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(Schema.Boolean, "Command executed successfully"), error: HttpApiError.BadRequest, @@ -130,6 +138,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + query: WorkspaceRoutingQuery, payload: TuiEvent.ToastShow.properties, success: described(Schema.Boolean, "Toast notification shown successfully"), }).annotateMerge( @@ -140,6 +149,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("publish", TuiPaths.publish, { + query: WorkspaceRoutingQuery, payload: TuiPublishPayload, success: described(Schema.Boolean, "Event published successfully"), error: HttpApiError.BadRequest, @@ -151,6 +161,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + query: WorkspaceRoutingQuery, payload: TuiEvent.SessionSelect.properties, success: described(Schema.Boolean, "Session selected successfully"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { + query: WorkspaceRoutingQuery, success: described(TuiRequestPayload, "Next TUI request"), }).annotateMerge( OpenApi.annotations({ @@ -171,6 +183,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + query: WorkspaceRoutingQuery, payload: Schema.Unknown, success: described(Schema.Boolean, "Response submitted successfully"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 3b0b2fa5b10..060c6c8a83e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" + +export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "V2SessionMessagesQuery" }) export const MessageGroup = HttpApiGroup.make("v2.message") .add( HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { params: { sessionID: SessionID }, - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Message order for the first page. Use desc for newest first or asc for oldest first.", - }), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionMessagesQuery" }), + query: MessagesQuery, success: Schema.Struct({ items: Schema.Array(SessionMessage.Message), cursor: Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 17ddcaeda3b..231f1915bb3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,67 +1,39 @@ -import { WorkspaceID } from "@/control-plane/schema" import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" import { Prompt } from "@/v2/session-prompt" import { SessionV2 } from "@/v2/session" -import { Schema, SchemaGetter } from "effect" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" +import { QueryBoolean } from "../query" + +export const SessionsQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.", + }), + ), +}).annotate({ identifier: "V2SessionsQuery" }) export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - directory: Schema.String.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - workspace: WorkspaceID.pipe(Schema.optional), - roots: Schema.Literals(["true", "false"]) - .pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), - ) - .pipe(Schema.optional), - start: Schema.NumberFromString.pipe(Schema.optional), - search: Schema.String.pipe(Schema.optional), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - directory: Schema.optional(Schema.Never), - path: Schema.optional(Schema.Never), - workspace: Schema.optional(Schema.Never), - roots: Schema.optional(Schema.Never), - start: Schema.optional(Schema.Never), - search: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionsQuery" }), + query: SessionsQuery, success: Schema.Struct({ items: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ @@ -82,6 +54,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: Schema.Struct({ prompt: Prompt, delivery: SessionV2.Delivery.pipe(Schema.optional), @@ -98,6 +71,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -110,6 +84,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -122,6 +97,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: Schema.Array(SessionMessage.Message), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 1a9732800db..1c40ae3cb8d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" @@ -40,6 +40,7 @@ export const WorkspaceApi = HttpApi.make("workspace") HttpApiGroup.make("workspace") .add( HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ @@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.Info), "Workspaces"), }).annotateMerge( OpenApi.annotations({ @@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("create", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, payload: CreatePayload, success: described(Workspace.Info, "Workspace created"), error: HttpApiError.BadRequest, @@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, { + query: WorkspaceRoutingQuery, success: described(HttpApiSchema.NoContent, "Workspace list synced"), }).annotateMerge( OpenApi.annotations({ @@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("status", WorkspacePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"), }).annotateMerge( OpenApi.annotations({ @@ -88,6 +93,7 @@ export const WorkspaceApi = HttpApi.make("workspace") ), HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { params: { id: Workspace.Info.fields.id }, + query: WorkspaceRoutingQuery, success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + query: WorkspaceRoutingQuery, payload: WarpPayload, success: described(HttpApiSchema.NoContent, "Session warped"), error: [ApiWorkspaceWarpError, ApiVcsApplyError], diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 753ba031380..3d0e8a06c09 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -24,7 +24,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h const providers = Effect.fn("ConfigHttpApi.providers")(function* () { const providers = yield* providerSvc.list() return { - providers: Object.values(providers), + providers: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), } }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index fa91b441178..55272fc2f29 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -6,7 +6,7 @@ import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@/util/effect-zod" +import * as EffectZod from "@opencode-ai/core/effect-zod" import { Worktree } from "@/worktree" import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 15049fcc553..7027e666cad 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -30,7 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" connected, ) return { - all: Object.values(providers), + all: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), connected: Object.keys(connected), } diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 3485d80fd63..92e37142b40 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message return handlers.handle( "messages", Effect.fn(function* (ctx) { + if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 558e34dd184..275fa2956c4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type const decodeCursor = Schema.decodeUnknownSync(SessionCursor) +function hasCursorFilter(query: { + readonly order?: unknown + readonly path?: unknown + readonly roots?: unknown + readonly start?: unknown + readonly search?: unknown +}) { + return ( + query.order !== undefined || + query.path !== undefined || + query.roots !== undefined || + query.start !== undefined || + query.search !== undefined + ) +} + +function hasCursorRoutingMismatch( + query: { readonly directory?: string; readonly workspace?: string }, + decoded: SessionCursor | undefined, +) { + if (!decoded) return false + if (query.directory !== undefined && query.directory !== decoded.directory) return true + return query.workspace !== undefined && query.workspace !== decoded.workspaceID +} + const sessionCursor = { encode( session: SessionV2.Info, @@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "sessions", Effect.fn(function* (ctx) { + if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), }) + if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({}) const order = decoded?.order ?? ctx.query.order ?? "desc" const filters = decoded ?? { directory: ctx.query.directory, diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 5a018f16da6..6f3c33a647a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -6,39 +6,13 @@ import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError } from "effect/unstable/httpapi" -import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError" const log = Log.create({ service: "server" }) -function badRequestResponse() { - return HttpServerResponse.jsonUnsafe( - { - data: {}, - errors: [], - success: false, - }, - { status: 400 }, - ) -} - -function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) { - if (response.status !== 400 || response.body._tag !== "Empty") return response - return badRequestResponse() -} - // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => effect.pipe( - Effect.catch((error) => { - if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse()) - return Effect.fail(error) - }), - Effect.map(normalizeEmptyBadRequest), Effect.catchCause((cause) => { - const schemaError = cause.reasons.filter(Cause.isDieReason).find((reason) => HttpApiSchemaError.is(reason.defect)) - if (schemaError) return Effect.succeed(badRequestResponse()) - const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false if (HttpServerError.isHttpServerError(reason.defect)) return false diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 017a70b91aa..0343ccfc87f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -9,11 +9,23 @@ import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Data, Effect, Layer } from "effect" +import { Context, Data, Effect, Layer, Schema } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" +// Query fields this middleware reads from the URL. Spread into every +// endpoint query schema in groups that apply WorkspaceRoutingMiddleware, +// otherwise HttpApi rejects requests carrying these params with 400. +// HttpApiMiddleware in effect-smol cannot declare query params today — +// remove this once upstream supports middleware-declared query schemas. +export const WorkspaceRoutingQueryFields = { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} + +export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields) + type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index b2ac719a2ae..156ebf6834a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -51,47 +51,37 @@ type OpenApiResponse = { content?: Record } -// Instance routes use middleware for directory/workspace resolution, but HttpApi -// doesn't surface middleware query params in the spec. Inject them explicitly. -const InstanceQueryParameters = [ - { - name: "directory", - in: "query", - required: false, - schema: { type: "string" }, - }, - { - name: "workspace", - in: "query", - required: false, - schema: { type: "string" }, - }, -] satisfies OpenApiParameter[] - // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) -const QueryParameterSchemas = { +const QueryParameterSchemas: Record = { + "GET /experimental/session start": { type: "number" }, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /experimental/session cursor": { type: "number" }, + "GET /experimental/session limit": { type: "number" }, + "GET /session start": { type: "number" }, + "GET /session limit": { type: "number" }, "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, -} satisfies Record + "GET /api/session limit": { type: "number" }, + "GET /api/session start": { type: "number" }, + "GET /api/session/{sessionID}/message limit": { type: "number" }, +} -const PathParameterSchemas = { +const PathParameterSchemas: Record = { sessionID: { type: "string", pattern: "^ses.*" }, messageID: { type: "string", pattern: "^msg.*" }, partID: { type: "string", pattern: "^prt.*" }, permissionID: { type: "string", pattern: "^per.*" }, ptyID: { type: "string", pattern: "^pty.*" }, -} satisfies Record +} -const LegacyComponentDescriptions = { +const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", LayoutConfig: "@deprecated Always uses stretch layout.", -} satisfies Record +} function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec @@ -122,7 +112,6 @@ function matchLegacyOpenApi(input: Record) { delete spec.components?.securitySchemes for (const [path, item] of Object.entries(spec.paths ?? {})) { - const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue @@ -183,14 +172,8 @@ function matchLegacyOpenApi(input: Record) { }, } } - if (!isInstanceRoute) continue - operation.parameters = [ - ...InstanceQueryParameters, - ...(operation.parameters ?? []).filter( - (param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"), - ), - ] - for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`) + const route = `${method.toUpperCase()} ${path}` + for (const param of operation.parameters ?? []) normalizeParameter(param, route) } } return input @@ -292,7 +275,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) { function normalizeComponentDescriptions(spec: OpenApiSpec) { for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { - const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] + const description = LegacyComponentDescriptions[name] if (description) { schema.description = description continue @@ -438,7 +421,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { } } // Simplest fix: generate the raw spec (without transform) to get correct schemas - const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi) const rawSchemas = raw.components?.schemas if (!rawSchemas) return for (const name of selfRefs) { @@ -507,15 +490,11 @@ function normalizeParameter(param: OpenApiParameter, route: string) { return } if (param.in === "query") { - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + const override = QueryParameterSchemas[`${route} ${param.name}`] if (override) { param.schema = override return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } if (QueryBooleanParameters.has(param.name)) { param.schema = { anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], @@ -527,7 +506,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } function pathParameterSchema(route: string, name: string) { - if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name in PathParameterSchemas) return PathParameterSchemas[name] if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 85f6124505f..3eb6f07b82b 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,8 +23,8 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" -import { zod, ZodOverride } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 - return value + return Math.max(0, value) } const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index abe51bcd610..1dd36ec53a9 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -2,8 +2,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f1709d5a2f0..e39bd85e9ac 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,8 +2,8 @@ import { Effect, Layer, Context, Schema } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" @@ -134,6 +134,7 @@ export const layer = Layer.effect( .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) const next = diffs.map((item) => { + if (item.file === undefined) return item const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 32a83704647..9b7daf7f0c9 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,8 +1,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" import { Database } from "@/storage/db" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 9a7a68a451c..c5dac9b1949 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -2,8 +2,8 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context, Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -17,6 +17,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" +import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -25,6 +26,15 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const KILO_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -230,6 +240,16 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + if (Flag.KILO_EXPERIMENTAL_CUSTOMIZE_SKILL) { + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s }), diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md new file mode 100644 index 00000000000..aa067d7880e --- /dev/null +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -0,0 +1,354 @@ + + +# Customizing opencode + +opencode validates its own config strictly and refuses to start when a field +is wrong. The shapes below are the accepted shapes. When in doubt, fetch +`https://opencode.ai/config.json` (the JSON Schema) and validate against it. + +Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"` +so the user's editor catches mistakes as they type. + +## Where files live + +| Scope | Path | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | +| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | +| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | +| Global agents | `~/.config/opencode/agent(s)/.md` | +| Project skills | `.opencode/skill(s)//SKILL.md` | +| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | +| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | + +Configs from each scope are deep-merged. Project overrides global. Unknown +top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. + +## opencode.json + +Every field is optional. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "username": "string", + "model": "provider/model-id", + "small_model": "provider/model-id", + "default_agent": "agent-name", + "shell": "/bin/zsh", + "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", + "share": "manual" | "auto" | "disabled", + "autoupdate": true | false | "notify", + "snapshot": true, + "instructions": ["AGENTS.md", "docs/style.md"], + + "skills": { + "paths": [".opencode/skills", "/abs/path/to/skills"], + "urls": ["https://example.com/.well-known/skills/"] + }, + + "agent": { + "my-agent": { + "model": "anthropic/claude-sonnet-4-6", + "mode": "subagent", + "description": "...", + "permission": { "edit": "deny" } + } + }, + + "command": { + "deploy": { "description": "...", "prompt": "..." } + }, + + "provider": { + "anthropic": { "options": { "apiKey": "..." } } + }, + "disabled_providers": ["openai"], + "enabled_providers": ["anthropic"], + + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": {} + }, + "remote-thing": { + "type": "remote", + "url": "https://...", + "headers": { "Authorization": "Bearer ..." } + } + }, + + "plugin": [ + "opencode-gemini-auth", + "opencode-foo@1.2.3", + "./local-plugin.ts", + ["opencode-bar", { "option": "value" }] + ], + + "permission": { + "edit": "deny", + "bash": { "git *": "allow", "*": "ask" } + }, + + "formatter": false, + "lsp": false, + + "experimental": { + "primary_tools": ["edit"], + "mcp_timeout": 30000 + }, + + "tool_output": { "max_lines": 200, "max_bytes": 8192 }, + + "compaction": { "auto": true, "tail_turns": 15 } +} +``` + +Shape notes worth being explicit about: + +- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. +- `skills` is an object with `paths` and/or `urls`, not an array. +- `agent` is an object keyed by agent name, not an array. +- `plugin` is an array of strings or `[name, options]` tuples, not an object. +- `mcp[name].command` is an array of strings, never a single string. `type` is required. +- `permission` is either a string action or an object keyed by tool name. + +## Skills + +opencode's skill loader scans for `**/SKILL.md` inside skill directories. The +file is named `SKILL.md` exactly, and lives in its own folder named after the +skill: + +``` +.opencode/skills/my-skill/SKILL.md +``` + +Frontmatter: + +```markdown +--- +name: my-skill +description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say. +--- + +# My Skill + +(skill body in markdown: instructions, examples, references) +``` + +- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. +- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. +- Optional: `license`, `compatibility`, `metadata` (string-string map). + +Register skills from non-default locations via `skills.paths` (scanned +recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of +skills). + +## Agents + +Two ways to define an agent. Use the file form for anything non-trivial. + +### Inline (in `opencode.json`) + +```json +{ + "agent": { + "my-reviewer": { + "description": "Reviews PRs for style violations.", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-6", + "permission": { "edit": "deny", "bash": "ask" }, + "prompt": "You are a strict PR reviewer..." + } + } +} +``` + +### File + +``` +.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md +``` + +```markdown +--- +description: Reviews PRs for style violations. +mode: subagent +model: anthropic/claude-sonnet-4-6 +permission: + edit: deny + bash: ask +--- + +You are a strict PR reviewer. Focus on... +``` + +The file body becomes the agent's `prompt`. Do not also put `prompt:` in the +frontmatter. + +`mode` is one of `"primary"`, `"subagent"`, `"all"`. + +Allowed top-level frontmatter fields: `name, model, variant, description, mode, +hidden, color, steps, options, permission, disable, temperature, top_p`. Any +unknown field is silently routed into `options`. + +To disable a built-in agent: `agent: { build: { disable: true } }`, or in a +file, `disable: true` in frontmatter. + +`default_agent` must point to a non-hidden, primary-mode agent. + +### Built-in agents + +opencode ships with `build`, `plan`, `general`, `explore`, plus optionally +`scout` (gated on `KILO_EXPERIMENTAL_SCOUT`). Hidden internal agents: +`compaction`, `title`, `summary`. To override a built-in's fields, define the +same key in `agent: { : { ... } }`. + +## Plugins + +`plugin:` is an array. Each entry is one of: + +```json +"plugin": [ + "opencode-gemini-auth", // npm spec, latest + "opencode-foo@1.2.3", // npm spec, pinned + "./local-plugin.ts", // file path, relative to the declaring config + "file:///abs/path/plugin.js", // file URL + ["opencode-bar", { "key": "val" }] // tuple form with options +] +``` + +Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in +`.opencode/plugin/` or `.opencode/plugins/`. + +A plugin module exports `default` (or any named export) of type +`Plugin = (input: PluginInput, options?) => Promise`. The export is a +function, not a plain object literal, and the function returns an object +(return `{}` if there is nothing to register). + +```ts +import type { Plugin } from "@kilocode/plugin" + +export default (async ({ client, project, directory, $ }) => { + return { + config: (cfg) => { + // cfg is the live merged config; mutate fields here. + }, + "tool.execute.before": async (input, output) => { + // mutate output.args before the tool runs + }, + } +}) satisfies Plugin +``` + +Hook surface (mutate `output` in place; return `void`): + +- `event(input)`: every bus event +- `config(cfg)`: once on init with the merged config +- `chat.message`, `chat.params`, `chat.headers` +- `tool.execute.before`, `tool.execute.after` +- `tool.definition` +- `command.execute.before` +- `shell.env` +- `permission.ask` +- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, + `experimental.session.compacting`, `experimental.compaction.autocontinue`, + `experimental.text.complete` + +Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, +`auth: { ... }`, `provider: { ... }`. + +## MCP servers + +`mcp:` is an object keyed by server name. Each server is discriminated by +`type`: + +```json +{ + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": { "BROWSER": "chromium" } + }, + "github": { + "type": "remote", + "url": "https://...", + "enabled": true, + "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } + }, + "old-server": { "enabled": false } + } +} +``` + +`command` is an array of strings. `type` is required. Use `enabled: false` to +disable a server inherited from a parent config. + +## Permissions + +```json +"permission": { + "edit": "deny", + "bash": { "git *": "allow", "rm *": "deny", "*": "ask" }, + "external_directory": { "~/secrets/**": "deny", "*": "allow" } +} +``` + +Actions: `"allow"`, `"ask"`, `"deny"`. + +Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an +object `{ pattern: action }`. Within an object, **insertion order matters**. +opencode evaluates the LAST matching rule, so put broad rules first and narrow +rules last. + +`permission: "allow"` (a string at the top level) is shorthand for "allow +everything" and is rarely what the user wants. + +Known permission keys: `read, edit, glob, grep, list, bash, task, +external_directory, todowrite, question, webfetch, websearch, codesearch, +repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, +question, webfetch, websearch, codesearch, doom_loop`) only accept a flat +action, not a per-pattern object. + +`external_directory` patterns are filesystem paths (use `~/`, absolute paths, +or globs like `~/projects/**`). + +Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on +the `plan` agent's permission ruleset (`edit: deny *`). + +## Escape hatches + +When a user's config is broken and opencode won't start, these env vars help: + +- `KILO_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` + and start from globals only. Run from the project directory, opencode loads, + the user edits the broken file, then they restart without the flag. +- `KILO_CONFIG=/path/to/file.json`: load an additional explicit config. +- `KILO_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: + inject inline JSON as a final local-scope merge. +- `KILO_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. +- `KILO_PURE=1`: skip external plugins entirely. +- `KILO_DISABLE_EXTERNAL_SKILLS=1`, + `KILO_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under + `~/.claude/` and `~/.agents/`. + +## When proposing edits + +- Validate against the schema before writing. If you are unsure of a field's + exact shape, fetch `https://opencode.ai/config.json` rather than guessing. +- Preserve `$schema` and any existing fields the user did not ask to change. +- For agent, skill, and plugin definitions, prefer creating new files in the + correct location over inlining everything in `opencode.json`. +- If the user's existing config is malformed, point them at the env-var escape + hatch above so they can edit from inside opencode without breaking their + session. +- opencode hard-fails on invalid config by design. There is no graceful + degradation, so get the shape right the first time. diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ea30f5afc7c..848a067c3d1 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,8 +10,8 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod } from "@opencode-ai/core/effect-zod" export const Patch = Schema.Struct({ hash: Schema.String, @@ -20,10 +20,13 @@ export const Patch = Schema.Struct({ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ - file: Schema.String, - patch: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + // Optional because legacy/imported `summary_diffs` on disk may omit + // file details and patch text. Required Schema rejected the whole + // session response and broke session loading on Desktop. + file: Schema.optional(Schema.String), + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "SnapshotFileDiff" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5b2df1e899a..bc4d8b8f17f 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 6f5f157cb8e..8a50c8f16d8 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -9,7 +9,7 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e714b86ae0c..e4e2e75b73a 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index bf01fc7d2d5..7ade166c5f7 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema, Scope } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" @@ -178,7 +178,7 @@ export const ReadTool = Tool.define( yield* ctx.ask({ permission: "read", - patterns: [filepath], + patterns: [path.relative(instance.worktree, filepath)], always: ["*"], metadata: {}, }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index e57cb1c2894..350c16664bb 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -17,7 +17,7 @@ import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@kilocode/plugin" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@/util/effect-zod" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 9ce7bece2b0..b6c263a4ce3 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 45c637863a6..f26e364b612 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { ShellID } from "./id" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 22e4e5671c8..c4d5bf7f4ae 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,6 +4,7 @@ import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" +import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Effect, Exit, Schema } from "effect" @@ -58,41 +59,25 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } - const canTask = next.permission.some((rule) => rule.permission === id) - const canTodo = next.permission.some((rule) => rule.permission === "todowrite") - const taskID = params.task_id const session = taskID ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const parent = yield* sessions.get(ctx.sessionID) + const parentAgent = parent.agent + ? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined const nextSession = session ?? (yield* sessions.create({ parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ - ...(parent.permission ?? []).filter( - (rule) => rule.permission === "external_directory" || rule.action === "deny", - ), - ...(canTodo - ? [] - : [ - { - permission: "todowrite" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(canTask - ? [] - : [ - { - permission: id, - pattern: "*" as const, - action: "deny" as const, - }, - ]), + ...deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + parentAgent, + subagent: next, + }), ...(cfg.experimental?.primary_tools?.map((item) => ({ pattern: "*", action: "allow" as const, @@ -144,8 +129,8 @@ export const TaskTool = Tool.define( }, agent: next.name, tools: { - ...(canTodo ? {} : { todowrite: false }), - ...(canTask ? {} : { task: false }), + ...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }), + ...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }), ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), }, parts, diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d9b92a23cc8..d87e1dcdb55 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import z from "zod" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts index 8d0ce9174fa..9ae5ee5a943 100644 --- a/packages/opencode/src/v2/auth.ts +++ b/packages/opencode/src/v2/auth.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" import { Identifier } from "@opencode-ai/core/util/identifier" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index c7c13890f23..83ca437efec 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,6 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Flag } from "@opencode-ai/core/flag/flag" import * as Schema from "effect/Schema" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts index db66199a59b..56357ab4006 100644 --- a/packages/opencode/src/v2/model.ts +++ b/packages/opencode/src/v2/model.ts @@ -1,4 +1,5 @@ -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" @@ -114,7 +115,7 @@ export class Info extends Schema.Class("Model.Info")({ released: DateTimeUtcFromMillis, }), cost: Cost.pipe(Schema.Array), - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, limit: Schema.Struct({ context: Schema.Int, input: Schema.Int.pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 7c768bd551a..fa211bd8c4e 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "./event" import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" @@ -118,12 +118,12 @@ export namespace Step { finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), snapshot: Schema.String.pipe(Schema.optional), @@ -305,7 +305,7 @@ export namespace Tool { export const RetryError = Schema.Struct({ message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), + statusCode: Schema.Finite.pipe(Schema.optional), isRetryable: Schema.Boolean, responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), responseBody: Schema.String.pipe(Schema.optional), @@ -320,7 +320,7 @@ export const Retried = EventV2.define({ aggregate: "sessionID", schema: { ...Base, - attempt: NonNegativeInt, + attempt: Schema.Finite, error: RetryError, }, }) diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index bb86f039b23..b3da6009f69 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -10,7 +10,7 @@ import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" -import { optionalOmitUndefined } from "@/util/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index d3e6f4047a1..168ebb51c37 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from "bun:test" import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@kilocode/sdk/v2" -import { Instance } from "../../src/project/instance" +import type { + Event, + EventMessagePartUpdated, + ToolStateCompleted, + ToolStatePending, + ToolStateRunning, +} from "@kilocode/sdk/v2" import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" @@ -36,6 +41,14 @@ function isToolCallUpdate( return update.sessionUpdate === "tool_call_update" } +function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) { + return sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter(isToolCallUpdate) + .find((u) => u.toolCallId === callID && u.status === "completed") +} + function toolEvent( sessionId: string, cwd: string, @@ -78,6 +91,46 @@ function toolEvent( return { directory: cwd, payload } } +function completedToolEvent( + sessionId: string, + cwd: string, + opts: { + callID: string + tool: string + input: Record + output: string + attachments?: ToolStateCompleted["attachments"] + }, +): GlobalEventEnvelope { + const state: ToolStateCompleted = { + status: "completed", + input: opts.input, + output: opts.output, + title: opts.tool, + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + ...(opts.attachments && { attachments: opts.attachments }), + } + const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, + type: "message.part.updated", + properties: { + sessionID: sessionId, + time: Date.now(), + part: { + id: `part_${opts.callID}`, + sessionID: sessionId, + messageID: `msg_${opts.callID}`, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }, + } + return { directory: cwd, payload } +} + function createEventStream() { const queue: GlobalEventEnvelope[] = [] const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] @@ -616,6 +669,130 @@ describe("acp.agent event subscription", () => { }) }) + test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("image-data").toString("base64") + + controller.push( + completedToolEvent(sessionId, cwd, { + callID: "call_image", + tool: "read", + input: { filePath: "/tmp/image.png" }, + output: "Image read successfully", + attachments: [ + { + id: "part_image", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "image/png", + filename: "image.png", + url: `data:image/png;base64,${data}`, + }, + { + id: "part_text", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,Zm9v", + }, + ], + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image read successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/png", data }, + }) + expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false) + expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2) + + stop() + }, + }) + }) + + test("replays completed tool image attachments as ACP tool content blocks", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, sessionUpdates, stop, sdk } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("replay-image").toString("base64") + + sdk.session.messages = async () => ({ + data: [ + { + info: { + role: "assistant", + sessionID: sessionId, + }, + parts: [ + { + id: "part_replay", + sessionID: sessionId, + messageID: "msg_replay", + type: "tool", + callID: "call_replay_image", + tool: "webfetch", + state: { + status: "completed", + input: { url: "https://example.com/image.png" }, + output: "Image fetched successfully", + title: "webfetch", + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + attachments: [ + { + id: "part_replay_image", + sessionID: sessionId, + messageID: "msg_replay", + type: "file", + mime: "image/jpeg", + filename: "image.jpg", + url: `data:image/jpeg;base64,${data}`, + }, + ], + }, + }, + ], + }, + ], + }) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image fetched successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/jpeg", data }, + }) + + stop() + }, + }) + }) + test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() await WithInstance.provide({ diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts new file mode 100644 index 00000000000..5ba6b54834b --- /dev/null +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -0,0 +1,141 @@ +/** + * Reproducer for opencode issue #26514: + * + * In Plan Mode (the `plan` agent), the main agent's edit/write tools are + * blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`). + * However, when the plan agent spawns a subagent via the `task` tool, the + * subagent retains full file modification capabilities — a security bypass. + * + * This test replicates the permission ruleset that would govern a + * `general` subagent when launched from a `plan` parent session, mirroring + * the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime + * subagent agent permissions, evaluated as in `session/prompt.ts`). + * + * The expected (secure) behavior is that the subagent inherits the plan + * mode read-only restriction and `edit`/`write` resolve to `deny`. On + * origin/dev this assertion fails because the parent **agent** permissions + * are not propagated to the subagent — only the parent **session** + * permissions are passed through, and Plan Mode's restrictions live on the + * agent, not the session. + */ +import { test, expect, afterEach } from "bun:test" +import { Effect } from "effect" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { Agent } from "../../src/agent/agent" +import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" +import { Permission } from "../../src/permission" + +afterEach(async () => { + await disposeAllInstances() +}) + +function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { + return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +} + +// `deriveSubagentSessionPermission` is imported from production. The test +// exercises the actual helper that task.ts uses to build the subagent's +// session permission, so any regression in that helper trips this test. + +test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const generalAgent = await load(tmp.path, (svc) => svc.get("general")) + + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + + // Simulate the plan-mode parent session: in real flow the plan + // session's `permission` field is empty (Plan Mode lives on the agent + // ruleset, not the session). So we pass [] through as the parent + // session permission, exactly like the actual code path. + const parentSessionPermission: Permission.Ruleset = [] + + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: generalAgent!, + }) + + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { + // Sibling check: even though `explore` is intrinsically read-only, the + // bug surface is the same. Including this case to document that the fix + // should propagate the parent **agent** permissions, not just deny edit + // when the subagent happens to already deny it. + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const explore = await load(tmp.path, (svc) => svc.get("explore")) + expect(planAgent).toBeDefined() + expect(explore).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) + + // Already deny — sanity check. + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { + // The most damaging case: a user-defined subagent with default + // permissions (allow-by-default, like `general`). The subagent must NOT + // be able to edit when the parent agent is `plan`. + await using tmp = await tmpdir({ + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const my = await load(tmp.path, (svc) => svc.get("my_subagent")) + expect(planAgent).toBeDefined() + expect(my).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: my!, + }) + const effective = Permission.merge(my!.permission, subagentSessionPermission) + + // BUG: on origin/dev edit resolves to "allow" because the plan + // agent's `edit: deny *` rule never reaches the subagent. + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + }, + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts new file mode 100644 index 00000000000..c9b3551d9a2 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -0,0 +1,55 @@ +/** + * Regression test for the TUI bootstrap aggregation helper. Replaces the + * pre-fix Promise.all behavior where the first rejection drowned every + * sibling endpoint's failure as an unhandled rejection. + */ +import { describe, expect, test } from "bun:test" +import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures" + +describe("aggregateFailures", () => { + test("returns null when every result is fulfilled", () => { + expect( + aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { name: "providers", result: { status: "fulfilled", value: 2 } }, + ]), + ).toBeNull() + }) + + test("names the failed endpoint when one rejects", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { + name: "providers", + result: { status: "rejected", reason: new Error("Service unavailable") }, + }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("1 of 2") + expect(err!.message).toContain("providers: Service unavailable") + }) + + test("names every failed endpoint when multiple reject", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } }, + { name: "providers", result: { status: "fulfilled", value: 1 } }, + { name: "agents", result: { status: "rejected", reason: { message: "boom" } } }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("2 of 3") + expect(err!.message).toContain("config: 400 Bad Request") + expect(err!.message).toContain("agents: boom") + }) + + test("attaches structured failure list under .cause", () => { + const reason = new Error("nope") + const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) + const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } + expect(cause.failures).toEqual([{ name: "providers", reason }]) + }) + + test("falls back to String() for opaque reasons", () => { + const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }]) + expect(err!.message).toContain("x: 42") + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx new file mode 100644 index 00000000000..d9ecdbe9d57 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -0,0 +1,120 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" + +export const worktree = "/tmp/opencode" +export const directory = `${worktree}/packages/opencode` + +export async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +export function json(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, + }) +} + +export function eventSource(): EventSource { + return { subscribe: async () => () => {} } +} + +type FetchHandler = (url: URL) => Response | Promise | undefined + +export function createFetch(override?: FetchHandler) { + const session = [] as URL[] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/session") session.push(url) + + const overridden = await override?.(url) + if (overridden) return overridden + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +type Ctx = { kv: ReturnType; sync: ReturnType } + +export async function mount(override?: FetchHandler) { + const calls = createFetch(override) + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const ctx: Ctx = { kv: useKV(), sync: useSync() } + onMount(() => { + sync = ctx.sync + kv = ctx.kv + done() + }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} diff --git a/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx new file mode 100644 index 00000000000..5fb7ece94dc --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx @@ -0,0 +1,47 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Reproducer for #26560 — TUI crashes with + * `TypeError: undefined is not an object (evaluating 'f.data.map')` + * when entering a session whose messages endpoint returns a non-2xx. + * The failure path is `sync.tsx#sync.session.sync` reading + * `messages.data!` while the SDK leaves `data` undefined on error. + */ +import { describe, expect, test } from "bun:test" +import { Global } from "@opencode-ai/core/global" +import { tmpdir } from "../../../fixture/fixture" +import { directory, json, mount } from "./sync-fixture" + +const sessionID = "ses_undef" + +describe("tui sync (#26560)", () => { + test("entering a session whose messages endpoint errors does not crash sync", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const sessionPayload = { + id: sessionID, + title: "broken", + time: { created: 0, updated: 0 }, + version: "1.14.42", + directory, + project_id: "proj_test", + } + const { app, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(sessionPayload) + if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 }) + if (url.pathname === `/session/${sessionID}/todo`) return json([]) + if (url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === "/session") return json([sessionPayload]) + return undefined + }) + + try { + await expect(sync.session.sync(sessionID)).resolves.toBeUndefined() + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 993484d3cac..f67257f6ceb 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -1,127 +1,8 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import { onMount } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" -import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" -import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" -import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" -import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" import { tmpdir } from "../../../fixture/fixture" - -const worktree = "/tmp/opencode" -const directory = `${worktree}/packages/opencode` - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function json(data: unknown) { - return new Response(JSON.stringify(data), { - headers: { "content-type": "application/json" }, - }) -} - -function eventSource(): EventSource { - return { - subscribe: async () => () => {}, - } -} - -function createFetch() { - const session = [] as URL[] - const fetch = (async (input: RequestInfo | URL) => { - const url = new URL(input instanceof Request ? input.url : String(input)) - if (url.pathname === "/session") session.push(url) - - switch (url.pathname) { - case "/agent": - case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": - case "/formatter": - case "/lsp": - return json([]) - case "/config": - case "/experimental/resource": - case "/mcp": - case "/provider/auth": - case "/session/status": - return json({}) - case "/config/providers": - return json({ providers: {}, default: {} }) - case "/experimental/console": - return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) - case "/path": - return json({ home: "", state: "", config: "", worktree, directory }) - case "/project/current": - return json({ id: "proj_test" }) - case "/provider": - return json({ all: [], default: {}, connected: [] }) - case "/session": - return json([]) - case "/vcs": - return json({ branch: "main" }) - } - - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - - return { fetch, session } -} - -async function mount() { - const calls = createFetch() - let sync!: ReturnType - let kv!: ReturnType - let done!: () => void - const ready = new Promise((resolve) => { - done = resolve - }) - - const app = await testRender(() => ( - - - - - - - { - sync = ctx.sync - kv = ctx.kv - done() - }} - /> - - - - - - - )) - - await ready - await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } -} - -function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { - const kv = useKV() - const sync = useSync() - - onMount(() => { - props.onReady({ kv, sync }) - }) - - return -} +import { mount } from "./sync-fixture" describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 10547c9f082..5afc85e3b5d 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -7,8 +7,9 @@ import type { MCP as MCPNS } from "../../src/mcp/index" // Per-client state for controlling mock behavior interface MockClientState { - tools: Array<{ name: string; description?: string; inputSchema: object }> + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number + requestCalls: number listToolsShouldFail: boolean listToolsError: string listPromptsShouldFail: boolean @@ -36,6 +37,7 @@ function getOrCreateClientState(name?: string): MockClientState { state = { tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], listToolsCalls: 0, + requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", listPromptsShouldFail: false, @@ -139,6 +141,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { tools: this._state?.tools ?? [] } } + async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) { + if (this._state) this._state.requestCalls++ + if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] }) + throw new Error(`unsupported request: ${request.method}`) + } + async listPrompts() { if (this._state?.listPromptsShouldFail) { throw new Error("listPrompts failed") @@ -205,6 +213,11 @@ function withInstance( } } +function statusName(status: Record | MCPNS.Status, server: string) { + if ("status" in status) return status.status + return status[server]?.status +} + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== @@ -433,6 +446,59 @@ test( ), ) +test( + "falls back when MCP output schema refs fail SDK tool discovery", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "stitch-like-server" + const serverState = getOrCreateClientState("stitch-like-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" + serverState.tools = [ + { + name: "render_screen", + description: "renders a screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + }, + ] + + const addResult = yield* mcp.add("stitch-like-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") + + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) + }), + ), +) + +test( + "does not fall back for non-schema MCP tool discovery errors", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "broken-server" + const serverState = getOrCreateClientState("broken-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "transport closed" + + const addResult = yield* mcp.add("broken-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "broken-server")).toBe("failed") + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(0) + }), + ), +) + // ======================================================================== // Test: disabled server via config // ======================================================================== diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 52434a6211a..312214ddaa1 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,6 +35,11 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["KILO_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["KILO_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +// Tests assert exact skill counts from disk discovery; the built-in +// customize-opencode skill is opt-in for stable channels and on by default +// for unstable channels (including "local" where CI runs). Disable it here +// so disk-discovery tests aren't off-by-one. +process.env["KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts new file mode 100644 index 00000000000..e6fa645e714 --- /dev/null +++ b/packages/opencode/test/provider/model-status.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigProvider } from "@/config/provider" +import { CatalogModelStatus, ModelStatus } from "@/provider/model-status" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" + +describe("provider model status schemas", () => { + test("keeps catalog status separate from normalized provider status", () => { + expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated") + expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow() + expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active") + }) + + test("accepts active status across public provider schemas", () => { + expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active") + expect( + Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }).status, + ).toBeUndefined() + expect( + Schema.decodeUnknownSync(Provider.Model)({ + id: "test-model", + providerID: "test-provider", + api: { + id: "test-model", + url: "", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + }).status, + ).toBe("active") + }) +}) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index ef069e435b5..a7c6e23d394 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -47,4 +47,41 @@ describe("config HttpApi", () => { lsp: false, }) }) + + test("serves config with active provider model status", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }, + }) + + const response = await app().request("/config", { + headers: { + "x-kilo-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }) + }) }) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 8e6a4c1c535..cacdfd23a3b 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -38,6 +38,10 @@ import { type Scenario } from "./types" void (await import("@opencode-ai/core/util/log")).init({ print: false }) +function cursor(input: Record) { + return Buffer.from(JSON.stringify(input)).toString("base64url") +} + const scenarios: Scenario[] = [ http.protected .get("/global/health", "global.health") @@ -520,7 +524,10 @@ const scenarios: Scenario[] = [ yield* ctx.worktreeRemove(ctx.state.directory) }), ), - http.protected.get("/experimental/session", "experimental.session.list").json(200, array), + http.protected + .get("/experimental/session", "experimental.session.list") + .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) + .json(200, array), http.protected.get("/experimental/resource", "experimental.resource.list").json(), http.protected .post("/sync/history", "sync.history.list") @@ -598,6 +605,64 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session", "v2.session.list.filters") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + order: "asc", + path: ".", + roots: "false", + start: "0", + search: "missing", + directory: ctx.directory ?? "", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ + id: "ses_httpapi_missing", + time: 0, + order: "desc", + direction: "next", + directory: ctx.directory, + }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor.invalid") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }), + search: "not-allowed-with-cursor", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ @@ -620,6 +685,53 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.params") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + order: "asc", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + order: "asc", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .at((ctx) => ({ diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts new file mode 100644 index 00000000000..6cb36c3bf31 --- /dev/null +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -0,0 +1,189 @@ +// Regression coverage for issue #26526's claim that promptAsync's +// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not — +// forkIn preserves Context.Reference values via standard fiber inheritance. +// +// The companion claim that the streaming prompt handler "captures and +// provides" those services is true and load-bearing: Stream.fromEffect's +// body runs detached from the request fiber's context, so the explicit +// Effect.provideService calls there are required, not defensive duplication. + +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Scope } from "effect" +import * as Stream from "effect/Stream" +import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { mkdir } from "node:fs/promises" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + InstanceLayer.layer, + Project.defaultLayer, + workspaceLayer, + ), +) + +const instanceContextTestLayer = instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const setupWorkspace = (kind: string) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id))) + registerAdapter(projectID, kind, localAdapter(dir)) + const workspace = yield* Workspace.Service.use((svc) => + svc.create({ type: kind, branch: null, extra: null, projectID }), + ) + return { dir, workspace } + }) + +type Capture = { directory?: string; workspaceID?: string } + +const captureInstance = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { directory: instance?.directory, workspaceID } satisfies Capture +}) + +describe("HttpApi handler context inheritance", () => { + // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits + // the request's Context — including InstanceRef and WorkspaceRef provided + // by InstanceContextMiddleware — without any explicit re-provide. + it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-fork") + const capture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/fork-probe", + Effect.gen(function* () { + const scope = yield* Scope.Scope + yield* Effect.gen(function* () { + yield* Deferred.succeed(capture, yield* captureInstance) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return HttpServerResponse.empty({ status: 204 }) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.post( + `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`, + ) + expect(response.status).toBe(204) + + const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds")) + expect(observed.directory).toBe(dir) + expect(observed.workspaceID).toBe(workspace.id) + }), + ) + + // Mirrors handlers/session.ts:255 prompt — the streaming handler reads + // InstanceRef/WorkspaceRef in the request fiber and re-provides them to + // the Stream.fromEffect body. This test locks in why the explicit + // provides are required: without them the stream body sees undefined. + it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-stream") + const withoutCapture = yield* Deferred.make() + const withCapture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/stream-probe-without", + Effect.gen(function* () { + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withoutCapture, yield* captureInstance) + return "" + }), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + yield* HttpRouter.add( + "POST", + "/stream-probe-with", + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withCapture, yield* captureInstance) + return "" + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}` + const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`) + yield* responseWithout.text + const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`) + yield* responseWith.text + + const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds")) + expect(without.directory).toBeUndefined() + expect(without.workspaceID).toBeUndefined() + + const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds")) + expect(withProvide.directory).toBe(dir) + expect(withProvide.workspaceID).toBe(workspace.id) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 0dbebec053b..74a9150ef63 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -21,6 +21,54 @@ function app() { return Server.Default().app } +function providerListHasFetch(list: unknown) { + if (!Array.isArray(list)) return false + return list.some((item: unknown) => { + if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false + if (item.id !== "google") return false + if (typeof item.options !== "object" || item.options === null) return false + return "fetch" in item.options + }) +} + +function hasProviderWithFetch(input: unknown, key: "all" | "providers") { + if (typeof input !== "object" || input === null) return false + if (key === "all") return "all" in input && providerListHasFetch(input.all) + return "providers" in input && providerListHasFetch(input.providers) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function providerList(input: unknown, key: "all" | "providers") { + if (!isRecord(input)) return [] + if (!Array.isArray(input[key])) return [] + return input[key] +} + +function providerByID(input: unknown, key: "all" | "providers", id: string) { + return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id) +} + +function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider) || !isRecord(provider.models)) return false + return Object.values(provider.models).some((model) => { + if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false + return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some( + (cost) => typeof cost === "number" && cost > 0, + ) + }) +} + +function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider)) return false + if (provider.name === "mutated-provider") return true + return isRecord(provider.options) && provider.options.mutatedByPlugin === true +} + function requestAuthorize(input: { app: ReturnType providerID: string @@ -76,6 +124,73 @@ function writeProviderAuthPlugin(dir: string) { }) } +function writeFunctionOptionsPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-function-options.ts"), + [ + "export default {", + ' id: "test.provider-function-options",', + " server: async () => ({", + " auth: {", + ' provider: "google",', + " loader: async (_getAuth, provider) => {", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return {", + ' apiKey: "",', + " fetch: async (input, init) => fetch(input, init),", + " }", + " },", + " methods: [{ type: 'api', label: 'API key' }],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function writeProviderModelsMutationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"), + [ + "export default {", + ' id: "test.provider-models-mutation",', + " server: async () => ({", + " provider: {", + ' id: "google",', + " models: async (provider) => {", + " const models = Object.fromEntries(", + " Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),", + " )", + ' provider.name = "mutated-provider"', + " provider.options = { ...provider.options, mutatedByPlugin: true }", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return models", + " },", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function withProviderProject(self: (dir: string) => Effect.Effect) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -136,4 +251,74 @@ describe("provider HttpApi", () => { }), ), ) + + it.live("serves provider lists when auth loaders add runtime fetch options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + const previous = process.env.KILO_AUTH_CONTENT + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeFunctionOptionsPlugin(dir) + yield* Effect.sync(() => { + process.env.KILO_AUTH_CONTENT = JSON.stringify({ + google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, + }) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) delete process.env.KILO_AUTH_CONTENT + if (previous !== undefined) process.env.KILO_AUTH_CONTENT = previous + }), + ) + const headers = { "x-kilo-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderWithFetch(providerBody, "all")).toBe(false) + expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) + }), + ) + + it.live("keeps provider.models hook input mutations out of provider state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeProviderModelsMutationPlugin(dir) + + const headers = { "x-kilo-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) + expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + }), + ) }) diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 00000000000..959c5fc3e85 --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { SessionID } from "../../src/session/schema" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { + FilePaths, + FileQuery, + FindFileQuery, + FindTextQuery, +} from "../../src/server/routes/instance/httpapi/groups/file" +import { + ExperimentalPaths, + SessionListQuery as ExperimentalSessionListQuery, + ToolListQuery, +} from "../../src/server/routes/instance/httpapi/groups/experimental" +import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { + ListQuery as SessionListQuery, + MessagesQuery, + SessionPaths, +} from "../../src/server/routes/instance/httpapi/groups/session" +import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" +import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" +import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES + +type Method = "get" | "post" | "put" | "delete" | "patch" +type QuerySchema = { readonly fields: Record } +type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string } +type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } +type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } + +const openApiDriftRoutes = [ + { method: "get", path: SessionPaths.list, query: SessionListQuery }, + { method: "get", path: SessionPaths.messages, query: MessagesQuery }, + { method: "get", path: FilePaths.findFile, query: FindFileQuery }, + { method: "get", path: FilePaths.findText, query: FindTextQuery }, + { method: "get", path: FilePaths.list, query: FileQuery }, + { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, + { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, + { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, + { method: "get", path: "/api/session", query: V2SessionsQuery }, + { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, +] satisfies Array<{ method: Method; path: string; query: QuerySchema }> + +const numericSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } }, + { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } }, + { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } }, + { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } }, + { + method: "get", + path: SessionPaths.messages, + name: "limit", + schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + }, + { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } }, + { method: "get", path: "/api/session", name: "start", schema: { type: "number" } }, + { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, +] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> + +function app() { + return Server.Default().app +} + +function request(url: string, init?: RequestInit) { + return Effect.promise(async () => app().request(url, init)) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +function openApiPath(path: string) { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") +} + +function queryParameters(operation: OpenApiOperation | undefined) { + return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) +} + +function queryParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) +} + +function assertAdvertisedQueryParamsAreRuntimeFields(input: { + readonly method: Method + readonly operation: OpenApiOperation | undefined + readonly path: string + readonly query: QuerySchema +}) { + const runtimeFields = new Set(Object.keys(input.query.fields)) + const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) + + expect( + advertisedOnly, + `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`, + ).toEqual([]) +} + +afterEach(async () => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +// Regression for the "OpenAPI advertises ?directory&workspace, runtime +// rejects them" drift class. Each affected route must accept both params +// without 400. +describe("httpapi query schema drift", () => { + const routingParams = (dir: string) => + `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` + + const expectNotSchemaRejection = (status: number, url: string) => { + expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) + } + + it.effect( + "boolean query schema accepts only true and false strings", + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(QueryBoolean) + const encode = Schema.encodeUnknownSync(QueryBoolean) + + expect(decode("true")).toBe(true) + expect(decode("false")).toBe(false) + expect(encode(true)).toBe("true") + expect(encode(false)).toBe("false") + + for (const input of ["1", "yes", "True", "", true, false]) { + expect(() => decode(input)).toThrow() + } + }), + ) + + it.effect( + "OpenAPI workspace query params are declared by runtime query schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const route of openApiDriftRoutes) { + assertAdvertisedQueryParamsAreRuntimeFields({ + ...route, + operation: spec.paths[openApiPath(route.path)]?.[route.method], + }) + } + }), + ) + + it.effect( + "OpenAPI numeric query params preserve generated SDK call shapes", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of numericSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(expected.schema) + } + }), + ) + + it.effect( + "drift assertion catches spec-only workspace query params", + Effect.sync(() => { + expect(() => + assertAdvertisedQueryParamsAreRuntimeFields({ + method: "get", + operation: { + parameters: [ + { name: "directory", in: "query" }, + { name: "workspace", in: "query" }, + ], + }, + path: "/fixture", + query: { fields: {} }, + }), + ).toThrow("advertises query params not accepted by runtime schema") + }), + ) + + it.live( + "session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "session messages accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/file accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find/file?query=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/text accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find?pattern=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file read accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/file?path=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental tool list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "vcs diff accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 3037c6f662b..7c4057add66 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -394,8 +394,16 @@ describe("HttpApi SDK", () => { const missing = yield* capture(() => sdk.session.get({ sessionID })) const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + // Result-tuple path: error body is preserved as-is so existing + // consumers reading `result.error.name` / `JSON.stringify(error)` + // keep working byte-for-byte. expect(missing.error).toEqual(expected) - expect(thrown).toEqual(expected) + // throwOnError path: SDK wraps the body in a real Error with the + // server's message, with the original parsed body preserved under + // `.cause.body`. + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toBe(expected.data.message) + expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected) return { status: missing.status, error: missing.error, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 0ccb63a9ca9..385f74c8713 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -297,6 +297,36 @@ describe("session HttpApi", () => { ), ) + it.live( + "serves sessions with migrated summary diffs missing file details", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const session = yield* createSession(tmp.path, { title: "legacy diff" }) + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ), + ) + + const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { + headers: { "x-kilo-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) + }), + ), + ) + it.live( "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => @@ -364,8 +394,15 @@ describe("session HttpApi", () => { headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, body: JSON.stringify({ title: "workspace session" }), }) + const messages = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, + { + headers: { "x-kilo-directory": tmp.path }, + }, + ) expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) expect( yield* Effect.sync(() => Database.use((db) => diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index b2af253d5b3..ac62dc18ed9 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -122,7 +122,7 @@ describe("sync HttpApi", () => { } }) - test("returns structured validation errors", async () => { + test.todo("returns structured validation errors", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await ExperimentalHttpApiServer.webHandler().handler( new Request(`http://localhost${SyncPaths.history}`, { diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts new file mode 100644 index 00000000000..77ad1bc279a --- /dev/null +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -0,0 +1,97 @@ +// Regression: a stored step-finish part with a negative token count made the +// messages endpoint 400. Some providers reported `outputTokens` excluding +// reasoning while also reporting `reasoningTokens` separately, so the +// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to +// negative. The pre-fix `safe()` clamp only guarded against non-finite. The +// strict `NonNegativeInt` schema then made every load of the message list +// fail to encode, killing Desktop boot for every user with such a row. +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { MessageID, PartID } from "../../src/session/schema" +import * as Database from "@/storage/db" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function seedNegativeTokenSession(directory: string) { + return Effect.promise(async () => + WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }), + ) +} + +describe("messages endpoint tolerates legacy negative token counts", () => { + it.live( + "returns 200 even when a step-finish part has tokens.output < 0", + Effect.acquireRelease( + Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const sessionID = yield* seedNegativeTokenSession(tmp.path) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + ), + ), + ) +}) diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts new file mode 100644 index 00000000000..31195dd0213 --- /dev/null +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -0,0 +1,74 @@ +/** + * Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true` + * path used to throw raw values (empty strings or POJOs from JSON-decoded + * error bodies). The TUI catches those and `e.message`/`e.stack` are + * undefined, so users see `[object Object]` or a blank crash. + * + * Both cases must throw a real `Error` instance with a non-empty `.message` + * extracted from the response body, plus `.status` and `.body` attached. + */ +import { afterEach, describe, expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2" +import { Server } from "../../src/server/server" +import * as Log from "@opencode-ai/core/util/log" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createKiloClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v2 SDK error shape", () => { + test("404 with NamedError body throws a real Error carrying the server message", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(err.message).toContain("Session not found") + expect(cause.status).toBe(404) + expect(cause.body).toMatchObject({ + name: "NotFoundError", + data: { message: expect.stringContaining("Session not found") }, + }) + }) + + test("400 with empty body throws a real Error naming the status", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + // POST /sync/history with `aggregate: -1` triggers schema validation + // that returns an empty 400 body (verified via plan-mode probe). + await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { status?: number } + expect(err.message.length).toBeGreaterThan(0) + expect(cause.status).toBe(400) + }) +}) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts new file mode 100644 index 00000000000..8079fc5ce43 --- /dev/null +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -0,0 +1,81 @@ +/** + * Regression test for the same bug class as #26574 (sibling of #26566 and + * #26553). The Desktop app calls GET /session//diff; before #26574 + * the response was Schema-encoded against `Snapshot.FileDiff` with + * `patch: Schema.String` (required), so any session whose stored + * `summary_diffs` had a row without `patch` returned HTTP 400 and the + * session never loaded. + * + * This test inserts a session row with a missing-patch diff entry and + * asserts that GET /session//diff returns 200 with the row intact. + */ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Server } from "@/server/server" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { Storage } from "@/storage/storage" +import { WithInstance } from "@/project/with-instance" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function pathFor(template: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) +} + +describe("session diff with missing patch (#26574)", () => { + it.live("GET /session//diff returns 200 when summary_diffs row has no patch", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })), + (t) => Effect.promise(() => t[Symbol.asyncDispose]()), + ) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Effect.runPromise( + Effect.provide( + Session.Service.use((s) => s.create({ title: "missing-patch" })), + Session.defaultLayer, + ), + ) + + // Mimic legacy/imported on-disk shape: a diff entry with no + // `patch` text. Pre-fix the typed response encoder rejects + // this and returns 400. + await Effect.runPromise( + Effect.provide( + Storage.Service.use((s) => + s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), + ), + Storage.defaultLayer, + ), + ) + + const headers = { "x-kilo-directory": tmp.path } + const response = await Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), { + headers, + }) + expect(response.status).toBe(200) + const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }> + expect(body).toHaveLength(1) + expect(body[0]?.file).toBe("legacy.txt") + expect(body[0]?.additions).toBe(1) + expect(body[0]?.patch).toBeUndefined() + }, + }), + ) + }), + ) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index a1feb1725f8..1ef8b490a21 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" @@ -165,4 +164,28 @@ describe("session messages endpoint", () => { }), ) }) + + test("accepts directory query used by workspace routing", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 1) + const app = Server.Default().app + + const res = await app.request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + + await svc.remove(session.id) + }, + }), + ) + }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index bee2184e5b3..e9628ce49fe 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -83,6 +83,26 @@ describe("Session.Info", () => { expect(Session.Info.zod.parse(input)).toEqual(input) }) + test("accepts migrated summary diffs without file details", () => { + const input = { + id: sessionID, + slug: "legacy-diff", + projectID, + directory: "/tmp/proj", + title: "Legacy diff", + version: "0.1.0", + summary: { + additions: 1, + deletions: 0, + files: 1, + diffs: [{ additions: 1, deletions: 0 }], + }, + time: { created: 1, updated: 2 }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + test("rejects unbranded session id", () => { const bad = { id: "not-a-session-id" } as unknown expect(() => decode(bad)).toThrow() diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 82b88a72fd6..671f62145c5 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -238,7 +238,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget - let diff: Array<{ file: string }> = [] + let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { diff = yield* summary.diff({ sessionID: session.id }) if (diff.length > 0) break diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts new file mode 100644 index 00000000000..2d30646976f --- /dev/null +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" +import { migrate } from "drizzle-orm/bun-sqlite/migrator" +import { readFileSync, readdirSync } from "fs" +import path from "path" + +const target = "20260507164347_add_workspace_time" + +function migrations() { + return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + timestamp: Number(entry.name.split("_")[0]), + sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"), + })) + .sort((a, b) => a.timestamp - b.timestamp) +} + +describe("workspace time migration", () => { + test("migrates existing workspace rows", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + const entries = migrations() + const index = entries.findIndex((entry) => entry.name === target) + + expect(index).toBeGreaterThan(0) + + migrate(db, entries.slice(0, index)) + sqlite.run( + "INSERT INTO project (id, worktree, vcs, name, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["project_1", "/tmp/project", "git", "project", 1, 1, "[]"], + ) + sqlite.run( + "INSERT INTO workspace (id, type, name, branch, directory, extra, project_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["workspace_1", "local", "main", "main", "/tmp/project", null, "project_1"], + ) + + expect(() => migrate(db, entries.slice(index))).not.toThrow() + expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 }) + }) +}) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9f6a0617eda..17af7b983e4 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "../../src/util/effect-zod" +import { toJsonSchema } from "@opencode-ai/core/effect-zod" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 695d96ec2fe..969364bad9f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -155,11 +155,24 @@ describe("tool.read external_directory permission", () => { yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") expect(read).toBeDefined() - expect(read!.patterns).toEqual([full(target)]) + expect(read!.patterns).toEqual([path.relative(dir, full(target))]) }), ) } + it.live("uses worktree-relative path for read permission so user rules match like edit/write", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "src", "secret.ts"), "shh") + + const { items, next } = asks() + yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns).toEqual([path.join("src", "secret.ts")]) + }), + ) + it.live("asks for directory-scoped external_directory permission when reading external directory", () => Effect.gen(function* () { const outer = yield* tmpdirScoped() diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 70cd8f0e647..ab3923d8e0a 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ed59bd9005b..04d27ab212a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.3.18", + "version": "7.3.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 4b37e546519..e557a9763a6 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -77,6 +77,42 @@ export type TuiKeys = { export type TuiKeymap = Keymap +/** + * Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. + */ +export type TuiCommand = { + title: string + value: string + description?: string + category?: string + keybind?: string + suggested?: boolean + hidden?: boolean + enabled?: boolean + slash?: { + name: string + aliases?: string[] + } + onSelect?: (dialog?: TuiDialogStack) => void | Promise +} + +/** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ +export type TuiCommandApi = { + /** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */ + register: (cb: () => TuiCommand[]) => () => void + /** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */ + trigger: (value: string) => void + /** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */ + show: () => void +} + export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" onClose: () => void @@ -461,6 +497,13 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp + /** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ + command?: TuiCommandApi keys: TuiKeys keymap: TuiKeymap route: { diff --git a/packages/script/package.json b/packages/script/package.json index 74d5106dcd7..30bf4362efe 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.3.18", + "version": "7.3.22", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index e0190ed33ec..bf0381d5ceb 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.3.18", + "version": "7.3.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index f6b696368b8..d30033953bb 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { KiloClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "./error-interceptor.js" export { type Config as KiloClientConfig, KiloClient } function pick(value: string | null, fallback?: string) { @@ -51,5 +52,6 @@ export function createKiloClient(config?: Config & { directory?: string }) { const client = createClient(config) client.interceptors.request.use((request) => rewrite(request, config?.directory)) + client.interceptors.error.use(wrapClientError) return new KiloClient({ client }) } diff --git a/packages/sdk/js/src/error-interceptor.ts b/packages/sdk/js/src/error-interceptor.ts new file mode 100644 index 00000000000..26407ecfc90 --- /dev/null +++ b/packages/sdk/js/src/error-interceptor.ts @@ -0,0 +1,51 @@ +/** + * Wrap whatever the generated client decoded from a non-2xx error body + * into a real `Error` so downstream formatters (TUI, plugins) get a + * useful `.message` instead of `[object Object]` or blank. The original + * parsed body and status live under `.cause` for callers that need + * structured fields. + * + * Only fires when the caller used `{ throwOnError: true }`. Callers that + * read `result.error` directly (the result-tuple path) get the parsed + * body unchanged so existing field-level reads (`.error.name`, + * `JSON.stringify(error)`, etc.) are byte-for-byte identical to before. + */ +export function wrapClientError( + error: unknown, + response: Response | undefined, + request: Request | undefined, + opts: { throwOnError?: boolean } | undefined, +): unknown { + if (!opts?.throwOnError) return error + if (error instanceof Error) return error + + // NamedError-shaped responses (the common case for opencode 4xx) come + // through as POJOs — extract a useful message first, then wrap. + if (typeof error === "object" && error !== null && Object.keys(error).length > 0) { + const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown } + const message = + (typeof obj.data?.message === "string" && obj.data.message) || + (typeof obj.message === "string" && obj.message) || + (typeof obj.name === "string" && obj.name) || + describe(request, response) + return new Error(message, { cause: { body: error, status: response?.status } }) + } + + if (typeof error === "string" && error.length > 0) { + return new Error(error, { cause: { body: error, status: response?.status } }) + } + + // Empty body / network failure / undefined / null / empty object. + const reason = response ? "(empty response body)" : "network error (no response)" + return new Error(`opencode server ${describe(request, response)}: ${reason}`, { + cause: { body: error, status: response?.status }, + }) +} + +function describe(request: Request | undefined, response: Response | undefined) { + const method = request?.method ?? "?" + const url = request?.url ?? "?" + const status = response?.status + const statusText = response?.statusText + return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}` +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe98..62e1b8fe8d4 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1065,7 +1065,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options?: { [key: string]: unknown } @@ -3012,7 +3012,7 @@ export type ProviderListResponses = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options: { [key: string]: unknown } diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index de272ba2aee..55ba10edb55 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { KiloClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "../error-interceptor.js" export { type Config as KiloClientConfig, KiloClient } function pick(value: string | null, fallback?: string, encode?: (value: string) => string) { @@ -84,24 +85,6 @@ export function createKiloClient(config?: Config & { directory?: string; experim return response }) - // The generated client falls back to throwing a literal `{}` when the server - // responds with an empty / unparseable error body, which surfaces as a bare - // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so - // downstream formatters get a useful message — but pass through any parsed - // JSON error body unchanged so existing consumers can still inspect fields. - client.interceptors.error.use((error, response, request) => { - const isEmpty = - error === undefined || - error === null || - error === "" || - (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) - if (!isEmpty) return error - const method = request?.method ?? "?" - const url = request?.url ?? "?" - if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) - const status = response.status - const statusText = response.statusText ? " " + response.statusText : "" - return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) - }) + client.interceptors.error.use(wrapClientError) return new KiloClient({ client }) } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 51cd7607d65..c0c57ea1574 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4163,6 +4163,13 @@ export class Session3 extends HeyApiClient { parameters?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + cursor?: string }, options?: Options, ) { @@ -4173,6 +4180,13 @@ export class Session3 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "path" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "search" }, + { in: "query", key: "cursor" }, ], }, ], @@ -4331,6 +4345,9 @@ export class Session3 extends HeyApiClient { sessionID: string directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + cursor?: string }, options?: Options, ) { @@ -4342,6 +4359,9 @@ export class Session3 extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 220278b8c28..4779f7cebd6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -119,8 +119,8 @@ export type PermissionRequest = { } export type SnapshotFileDiff = { - file: string - patch: string + file?: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -1060,7 +1060,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" provider?: { npm?: string api?: string @@ -1520,7 +1520,7 @@ export type VcsFileStatus = { export type VcsFileDiff = { file: string - patch: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -6235,6 +6235,16 @@ export type V2SessionListData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters. + */ + cursor?: string } url: "/api/session" } @@ -6352,6 +6362,12 @@ export type V2SessionMessagesData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. + */ + cursor?: string } url: "/api/session/{sessionID}/message" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e19ead9b327..8e7d56a9874 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -115,18 +115,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -446,18 +446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -490,18 +490,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -532,18 +532,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -595,18 +595,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -656,18 +656,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -710,18 +710,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -797,18 +797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -861,18 +861,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "provider", @@ -931,18 +931,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -985,18 +985,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1031,18 +1031,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1092,18 +1092,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1156,18 +1156,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1220,18 +1220,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "roots", @@ -1332,18 +1332,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1380,18 +1380,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "pattern", @@ -1496,18 +1496,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1580,18 +1580,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1636,18 +1636,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1692,18 +1692,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1744,18 +1744,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1792,18 +1792,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1837,18 +1837,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1881,18 +1881,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1925,18 +1925,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1973,18 +1973,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "mode", @@ -2030,18 +2030,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2074,18 +2074,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2152,18 +2152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2200,18 +2200,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2248,18 +2248,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2312,18 +2312,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2360,18 +2360,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2408,18 +2408,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2454,18 +2454,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2535,22 +2535,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.start", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2558,6 +2542,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2616,22 +2616,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2639,6 +2623,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2687,22 +2687,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2710,6 +2694,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2775,22 +2775,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.authenticate", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2798,6 +2782,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2847,22 +2847,6 @@ "tags": ["mcp"], "operationId": "mcp.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2870,6 +2854,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2899,22 +2899,6 @@ "tags": ["mcp"], "operationId": "mcp.disconnect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2922,6 +2906,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2954,18 +2954,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3002,18 +3002,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3046,18 +3046,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3087,22 +3087,6 @@ "tags": ["project"], "operationId": "project.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "projectID", "in": "path", @@ -3110,6 +3094,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3202,18 +3202,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3263,18 +3263,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3309,18 +3309,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3393,22 +3393,6 @@ "tags": ["pty"], "operationId": "pty.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3417,6 +3401,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3454,22 +3454,6 @@ "tags": ["pty"], "operationId": "pty.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3478,6 +3462,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3545,22 +3545,6 @@ "tags": ["pty"], "operationId": "pty.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3569,6 +3553,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3609,22 +3609,6 @@ "tags": ["pty"], "operationId": "pty.connectToken", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3633,6 +3617,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3697,18 +3697,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3742,22 +3742,6 @@ "tags": ["question"], "operationId": "question.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3766,6 +3750,22 @@ "pattern": "^que.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3836,22 +3836,6 @@ "tags": ["question"], "operationId": "question.reject", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3860,6 +3844,22 @@ "pattern": "^que.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3913,18 +3913,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3958,22 +3958,6 @@ "tags": ["permission"], "operationId": "permission.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3982,6 +3966,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4055,18 +4055,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4122,18 +4122,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4170,22 +4170,6 @@ "tags": ["provider"], "operationId": "provider.oauth.authorize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4193,6 +4177,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4255,22 +4255,6 @@ "tags": ["provider"], "operationId": "provider.oauth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4278,6 +4262,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4341,18 +4341,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -4444,18 +4444,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4541,18 +4541,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4596,22 +4596,6 @@ "tags": ["session"], "operationId": "session.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4620,6 +4604,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4667,22 +4667,6 @@ "tags": ["session"], "operationId": "session.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4691,6 +4675,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4739,22 +4739,6 @@ "tags": ["session"], "operationId": "session.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4763,6 +4747,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4839,22 +4839,6 @@ "tags": ["session"], "operationId": "session.children", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4863,6 +4847,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4916,22 +4916,6 @@ "tags": ["session"], "operationId": "session.todo", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4940,6 +4924,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4993,22 +4993,6 @@ "tags": ["session"], "operationId": "session.diff", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5018,6 +5002,22 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "messageID", "in": "query", @@ -5059,22 +5059,6 @@ "tags": ["session"], "operationId": "session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5084,6 +5068,22 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "limit", "in": "query", @@ -5165,22 +5165,6 @@ "tags": ["session"], "operationId": "session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5189,6 +5173,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5319,22 +5319,6 @@ "tags": ["session"], "operationId": "session.message", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5352,6 +5336,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5413,22 +5413,6 @@ "tags": ["session"], "operationId": "session.deleteMessage", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5446,6 +5430,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5496,22 +5496,6 @@ "tags": ["session"], "operationId": "session.fork", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5520,6 +5504,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5574,22 +5574,6 @@ "tags": ["session"], "operationId": "session.abort", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5598,6 +5582,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5648,22 +5648,6 @@ "tags": ["session"], "operationId": "session.init", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5672,6 +5656,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5744,22 +5744,6 @@ "tags": ["session"], "operationId": "session.share", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5768,6 +5752,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5815,22 +5815,6 @@ "tags": ["session"], "operationId": "session.unshare", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5839,6 +5823,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5888,22 +5888,6 @@ "tags": ["session"], "operationId": "session.summarize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5912,6 +5896,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5984,22 +5984,6 @@ "tags": ["session"], "operationId": "session.prompt_async", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6008,6 +5992,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6119,22 +6119,6 @@ "tags": ["session"], "operationId": "session.command", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6143,6 +6127,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6264,22 +6264,6 @@ "tags": ["session"], "operationId": "session.shell", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6288,6 +6272,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6386,22 +6386,6 @@ "tags": ["session"], "operationId": "session.revert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6410,6 +6394,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6478,22 +6478,6 @@ "tags": ["session"], "operationId": "session.unrevert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6502,6 +6486,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6551,22 +6551,6 @@ "tags": ["session"], "operationId": "permission.respond", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6584,6 +6568,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6652,22 +6652,6 @@ "tags": ["session"], "operationId": "part.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6694,6 +6678,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6741,22 +6741,6 @@ "tags": ["session"], "operationId": "part.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6783,6 +6767,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6843,18 +6843,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6888,18 +6888,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6993,18 +6993,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7071,18 +7071,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7162,18 +7162,84 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "roots", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters." + }, + "required": false } ], "responses": { @@ -7213,22 +7279,6 @@ "tags": ["v2"], "operationId": "v2.session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7237,6 +7287,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7285,22 +7351,6 @@ "tags": ["v2"], "operationId": "v2.session.compact", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7309,6 +7359,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7331,22 +7397,6 @@ "tags": ["v2"], "operationId": "v2.session.wait", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7355,6 +7405,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7377,22 +7443,6 @@ "tags": ["v2"], "operationId": "v2.session.context", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7401,6 +7451,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7433,22 +7499,6 @@ "tags": ["v2 messages"], "operationId": "v2.session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7457,6 +7507,48 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + "required": false } ], "responses": { @@ -7499,18 +7591,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7570,18 +7662,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7615,18 +7707,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7660,18 +7752,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7705,18 +7797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7750,18 +7842,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7795,18 +7887,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7840,18 +7932,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7911,18 +8003,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7983,18 +8075,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8060,18 +8152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8142,18 +8234,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8195,18 +8287,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8247,18 +8339,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8308,18 +8400,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8354,18 +8446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8445,18 +8537,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8482,18 +8574,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8538,22 +8630,6 @@ "tags": ["workspace"], "operationId": "experimental.workspace.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "id", "in": "path", @@ -8562,6 +8638,22 @@ "pattern": "^wrk.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -8604,18 +8696,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8684,22 +8776,6 @@ "tags": ["pty"], "operationId": "pty.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -8708,6 +8784,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -9108,19 +9200,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["additions", "deletions"], "additionalProperties": false }, "ProviderAuthError": { @@ -9955,31 +10045,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10163,12 +10247,10 @@ "type": "string" }, "start": { - "type": "integer", - "minimum": 0 + "type": "number" }, "end": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["value", "start", "end"], @@ -10551,31 +10633,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10843,16 +10919,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -11725,7 +11798,7 @@ }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated"] + "enum": ["alpha", "beta", "deprecated", "active"] }, "provider": { "type": "object", @@ -12675,16 +12748,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -13002,12 +13072,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -13027,19 +13095,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["file", "additions", "deletions"], "additionalProperties": false }, "VcsApplyError": { @@ -14155,16 +14221,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -14749,27 +14812,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -15487,8 +15545,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" @@ -16991,27 +17048,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -17599,8 +17651,7 @@ "type": "string" }, "statusCode": { - "type": "integer", - "minimum": 0 + "type": "number" }, "isRetryable": { "type": "boolean" @@ -17644,8 +17695,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 6999aa7ba65..0a874b2191d 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.3.18", + "version": "7.3.22", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 7f265caddc7..43362e1a026 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.3.18", + "version": "7.3.22", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 6a9267d2c70..04923147042 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -12,7 +12,8 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff +type SnapshotDiff = SnapshotFileDiff & { file: string } +type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff export type ViewDiff = { file: string diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 35d823facfd..debfbcb4351 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -62,7 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } +type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } function diff(value: unknown): value is ReviewDiff { @@ -108,7 +113,7 @@ export interface SessionReviewProps { classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element - diffs: ReviewDiff[] + diffs: RawReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise lineCommentMention?: LineCommentEditorProps["mention"] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 944962579c8..8ff280eee8b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -90,6 +90,12 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +type SummaryDiff = SnapshotFileDiff & { file: string } + +function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" +} + const hidden = new Set(["todowrite"]) function partState(part: PartType, showReasoningSummaries: boolean) { @@ -169,7 +175,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: SnapshotFileDiff[] = [] + const emptyDiffs: SummaryDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -238,7 +244,8 @@ export function SessionTurn( const seen = new Set() return files - .reduceRight((result, diff) => { + .reduceRight((result, diff) => { + if (!summaryDiff(diff)) return result if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff) From 2a3975ea29acfe4e5c4a4cd293de7e6f9b789e86 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 10:31:45 -0400 Subject: [PATCH 058/201] fix(jetbrains): support copying session text --- .changeset/steady-session-selection.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 33 ++- .../session/ui/SessionMessageListPanel.kt | 6 +- .../session/ui/selection/SessionSelection.kt | 255 ++++++++++++++++++ .../client/session/views/LoginRequiredView.kt | 4 +- .../client/session/views/MessageView.kt | 6 +- .../client/session/views/PlanExitView.kt | 7 +- .../client/session/views/ReasoningView.kt | 8 +- .../kilocode/client/session/views/TextView.kt | 4 +- .../kilocode/client/session/views/ToolView.kt | 12 +- .../kilocode/client/session/views/TurnView.kt | 4 +- .../client/session/views/ViewFactory.kt | 41 ++- .../session/views/base/BaseQuestionView.kt | 6 +- .../views/permission/PermissionView.kt | 23 +- .../views/question/QuestionResultView.kt | 18 +- .../session/views/question/QuestionView.kt | 15 +- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 2 + .../ai/kilocode/client/ui/md/MdViewFactory.kt | 10 +- .../kilocode/client/ui/md/MdViewHtmlPane.kt | 25 ++ .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 13 + .../session/ui/SessionSelectionCopyTest.kt | 125 +++++++++ .../ui/selection/SessionSelectionTest.kt | 72 +++++ 22 files changed, 658 insertions(+), 36 deletions(-) create mode 100644 .changeset/steady-session-selection.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt diff --git a/.changeset/steady-session-selection.md b/.changeset/steady-session-selection.md new file mode 100644 index 00000000000..defbe0c352f --- /dev/null +++ b/.changeset/steady-session-selection.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support coherent selection and copy behavior across JetBrains session transcript fragments. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 193d6031864..2a6abcea561 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -22,6 +22,7 @@ import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.header.SessionHeaderPanel +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.EVENT_FLUSH_MS @@ -38,6 +39,11 @@ import ai.kilocode.log.ChatLogSummary import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog import com.intellij.ide.BrowserUtil +import com.intellij.ide.TextCopyProvider +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -79,7 +85,7 @@ class SessionUi( private val manager: SessionManager? = null, private val workspaces: KiloWorkspaceService = service(), private val migration: MigrationUiController = service(), -) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget { +) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget, UiDataProvider { companion object { private val LOG = KiloLog.create(SessionUi::class.java) @@ -143,12 +149,22 @@ class SessionUi( private var empty: EmptySessionPanel? = null private var modalFocus: (() -> JComponent)? = null private var style = SessionEditorStyle.current() + private val selection = SessionSelection() + private val copy = object : TextCopyProvider() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun getTextLinesToCopy(): Collection? { + val text = selection.selectedText()?.takeIf { it.isNotEmpty() } ?: return null + return listOf(text) + } + } private var editorTheme = style.editorScheme private var colorTheme = UIManager.getLookAndFeel() private var disposed = false init { buildUi() + Disposer.register(this, selection) scroll.show(body(controller.model.state)) bindUi() bindStyle() @@ -178,6 +194,10 @@ class SessionUi( internal fun currentStyle() = style + override fun uiDataSnapshot(sink: DataSink) { + sink[PlatformDataKeys.COPY_PROVIDER] = copy + } + @RequiresEdt internal fun canDisposeInactive(): Boolean = controller.model.state is SessionState.Idle @@ -259,12 +279,18 @@ class SessionUi( reject = { id -> controller.rejectQuestion(id) }, follow = { scroll.following() }, scroll = { scroll.followBottom(it) }, + selection = selection, ) permission = PermissionView( reply = { id, dto -> controller.replyPermission(id, dto) }, + selection = selection, ) - login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() }) - messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl) + login = LoginRequiredView( + openProfile = { controller.openProfile() }, + dismiss = { controller.dismissLoginRequired() }, + selection = selection, + ) + messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl, selection) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) @@ -524,6 +550,7 @@ class SessionUi( override fun applyStyle(style: SessionEditorStyle) { this.style = style + selection.applyStyle(style) editorTheme = style.editorScheme colorTheme = UIManager.getLookAndFeel() background = style.editorBackground diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 60977888672..d2255359cac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView @@ -49,6 +50,7 @@ class SessionMessageListPanel( private val login: LoginRequiredView? = null, private val openFile: (String) -> Unit, private val openUrl: (String) -> Unit = {}, + private val selection: SessionSelection? = null, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( @@ -177,7 +179,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl) + val tv = TurnView(turn.id, openFile, style, openUrl, selection) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -233,7 +235,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl) + val tv = TurnView(turn.id, openFile, style, openUrl, selection) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt new file mode 100644 index 00000000000..9a7076951d1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt @@ -0,0 +1,255 @@ +package ai.kilocode.client.session.ui.selection + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.event.SelectionEvent +import com.intellij.openapi.editor.event.SelectionListener +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Color +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.event.CaretEvent +import javax.swing.event.CaretListener +import javax.swing.text.JTextComponent + +class SessionSelection : Disposable { + private val items = linkedSetOf() + private var active: Item? = null + private var style: SessionEditorStyle? = null + private var clearing = false + private var disposed = false + + @RequiresEdt + fun selectedText(): String? { + active?.selectedText()?.takeIf { it.isNotEmpty() }?.let { return it } + val item = items.toList().asReversed().firstOrNull { !it.selectedText().isNullOrEmpty() } + if (item == null) { + active = null + return null + } + active = item + clearExcept(item) + return item.selectedText()?.takeIf { it.isNotEmpty() } + } + + @RequiresEdt + fun register(component: JTextComponent, parent: Disposable? = null): Disposable { + val item = TextItem(component) + add(item, parent) + return item + } + + @RequiresEdt + fun register(field: EditorTextField, parent: Disposable? = null): Disposable { + val item = FieldItem(field) + add(item, parent) + return item + } + + @RequiresEdt + fun register(editor: EditorEx, parent: Disposable? = null): Disposable { + val item = EditorItem(editor) + add(item, parent) + return item + } + + @RequiresEdt + private fun clearExcept(item: Item) { + if (clearing) return + clearing = true + try { + for (entry in items) { + if (entry !== item) entry.clearSelection() + } + } finally { + clearing = false + } + } + + @RequiresEdt + fun clear() { + if (clearing) return + clearing = true + try { + for (entry in items) entry.clearSelection() + active = null + } finally { + clearing = false + } + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle) { + this.style = style + for (item in items) item.applyStyle(style) + } + + @RequiresEdt + override fun dispose() { + disposed = true + clear() + val copy = items.toList() + for (item in copy) item.dispose() + items.clear() + active = null + } + + @RequiresEdt + private fun add(item: Item, parent: Disposable?) { + if (disposed) return + items.add(item) + style?.let(item::applyStyle) + parent?.let { Disposer.register(it, item) } + } + + @RequiresEdt + private fun changed(item: Item) { + if (clearing || item.disposed) return + if (!items.contains(item)) return + if (!item.selectedText().isNullOrEmpty()) { + active = item + clearExcept(item) + return + } + if (active === item) active = null + } + + @RequiresEdt + private fun started(item: Item) { + if (clearing || item.disposed) return + if (!items.contains(item)) return + active = item + clearExcept(item) + } + + private interface Item : Disposable { + val disposed: Boolean + fun selectedText(): String? + fun clearSelection() + fun applyStyle(style: SessionEditorStyle) + } + + private inner class TextItem(private val component: JTextComponent) : Item, CaretListener { + private val mouse = object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) = started(this@TextItem) + } + + override var disposed = false + private set + + init { + component.caret.isSelectionVisible = true + component.caret.isVisible = false + component.isFocusable = true + component.isRequestFocusEnabled = true + component.addCaretListener(this) + component.addMouseListener(mouse) + } + + override fun caretUpdate(e: CaretEvent) = changed(this) + + override fun selectedText(): String? = component.selectedText + + override fun clearSelection() { + val pos = component.selectionStart.coerceIn(0, component.document.length) + component.select(pos, pos) + } + + override fun applyStyle(style: SessionEditorStyle) { + selectionColors(style, component.selectionColor, component.selectedTextColor).let { + component.selectionColor = it.first + component.selectedTextColor = it.second + } + } + + override fun dispose() { + if (disposed) return + disposed = true + component.removeCaretListener(this) + component.removeMouseListener(mouse) + if (active === this) active = null + items.remove(this) + } + } + + private inner class FieldItem(private val field: EditorTextField) : Item, SelectionListener { + private var editor: EditorEx? = null + private var reg: Disposable? = null + override var disposed = false + private set + + init { + field.getEditor(false)?.let(::bind) + field.addSettingsProvider { ed -> bind(ed) } + } + + override fun selectedText(): String? = editor?.selectionModel?.selectedText + + override fun clearSelection() { + editor?.selectionModel?.removeSelection() + } + + override fun applyStyle(style: SessionEditorStyle) { + editor?.let(style::applyToEditor) + } + + override fun selectionChanged(e: SelectionEvent) = changed(this) + + override fun dispose() { + if (disposed) return + disposed = true + reg?.let(Disposer::dispose) + reg = null + editor = null + if (active === this) active = null + items.remove(this) + } + + private fun bind(editor: EditorEx) { + if (disposed || this.editor != null) return + this.editor = editor + val disposable = Disposer.newDisposable("Session selection editor") + reg = disposable + editor.selectionModel.addSelectionListener(this, disposable) + style?.let(::applyStyle) + } + } + + private inner class EditorItem(private val editor: EditorEx) : Item, SelectionListener { + override var disposed = false + private set + + init { + editor.selectionModel.addSelectionListener(this, this) + } + + override fun selectionChanged(e: SelectionEvent) = changed(this) + + override fun selectedText(): String? = editor.selectionModel.selectedText + + override fun clearSelection() { + editor.selectionModel.removeSelection() + } + + override fun applyStyle(style: SessionEditorStyle) { + style.applyToEditor(editor) + } + + override fun dispose() { + if (disposed) return + disposed = true + if (active === this) active = null + items.remove(this) + } + } + + private fun selectionColors(style: SessionEditorStyle, bg: Color?, fg: Color?): Pair { + val scheme = style.editorScheme + return (scheme.getColor(EditorColors.SELECTION_BACKGROUND_COLOR) ?: bg ?: style.editorBackground) to + (scheme.getColor(EditorColors.SELECTION_FOREGROUND_COLOR) ?: fg ?: style.editorForeground) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt index f38d72f2600..b45fbb58869 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import com.intellij.util.concurrency.annotations.RequiresEdt @@ -21,11 +22,12 @@ import javax.swing.JButton class LoginRequiredView( private val openProfile: () -> Unit, private val dismiss: () -> Unit, + selection: SessionSelection? = null, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { override val sessionViewKind = SessionView.Kind.Default - private val card = BaseQuestionView() + private val card = BaseQuestionView(selection) private val ID_DISMISS = "dismiss" private val ID_OPEN = "open" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index c753a761243..68d48f9d9b3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -8,6 +8,7 @@ import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle @@ -34,6 +35,7 @@ class MessageView( private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, + private val selection: SessionSelection? = null, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), Disposable, SessionEditorStyleTarget, SessionView { @@ -174,9 +176,9 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile, openUrl) + ViewFactory.createUser(content, openFile, openUrl, selection) } else { - ViewFactory.create(content, openFile, openUrl) + ViewFactory.create(content, openFile, openUrl, selection) } /** Append a streaming delta to the renderer for [contentId]. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index 70afc6fdf5f..a64af5f93db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -5,12 +5,15 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdViewFactory import com.intellij.openapi.util.Disposer import java.awt.BorderLayout -class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() { +class PlanExitView(tool: Tool, openFile: (String) -> Unit, selection: SessionSelection? = null) : PartView() { + constructor(tool: Tool, openFile: (String) -> Unit) : this(tool, openFile, null) + companion object { fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED } @@ -18,7 +21,7 @@ class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() { override val contentId: String = tool.id private var item = tool - private val md = MdViewFactory.create(SessionEditorStyle.current()) + private val md = MdViewFactory.create(SessionEditorStyle.current(), selection) init { layout = BorderLayout() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 790fc88b492..d7835d920f6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle @@ -28,7 +29,8 @@ import javax.swing.Scrollable class ReasoningView( reasoning: Reasoning, openUrl: (String) -> Unit = {}, - private val parts: ReasoningParts = reasoningParts(), + selection: SessionSelection? = null, + private val parts: ReasoningParts = reasoningParts(selection), ) : SecondarySessionPartView(parts.header, parts.scroll) { @@ -133,8 +135,8 @@ class ReasoningParts( val icon: JBLabel, ) -private fun reasoningParts(): ReasoningParts { - val md = MdViewFactory.create(SessionEditorStyle.current()) +private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { + val md = MdViewFactory.create(SessionEditorStyle.current(), selection) val panel = TrackPanel().apply { isOpaque = true background = SessionUiStyle.View.surface() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index a002af8415f..74e7e1d494f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory @@ -18,11 +19,12 @@ class TextView( text: Text, transparent: Boolean = false, openUrl: (String) -> Unit = {}, + selection: SessionSelection? = null, ) : PartView() { override val contentId: String = text.id - val md: MdView = MdViewFactory.create(SessionEditorStyle.current()) + val md: MdView = MdViewFactory.create(SessionEditorStyle.current(), selection) init { layout = BorderLayout() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index b5ad3638aa3..b1736ce7282 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -8,6 +8,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle @@ -32,7 +33,11 @@ import javax.swing.JPanel import javax.swing.ScrollPaneConstants /** Renders non-read tool calls with VS Code-inspired rows/cards. */ -class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : +class ToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool), +) : SecondarySessionPartView(parts.header, parts.scroll) { override val contentId: String = tool.id @@ -41,6 +46,7 @@ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : private var style = SessionEditorStyle.current() init { + selection?.register(parts.text, this) bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) parts.text.text = preview(item) applyStyle(style) @@ -147,6 +153,7 @@ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : class ReadToolView( tool: Tool, openFile: (String) -> Unit = {}, + private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, openFile), ) : SecondarySessionPartView(parts.header, parts.scroll, expandable = false) { @@ -160,6 +167,7 @@ class ReadToolView( private var style = SessionEditorStyle.current() init { + selection?.register(parts.text, this) bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) parts.text.text = preview(item) applyStyle(style) @@ -322,7 +330,7 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar val text = JBTextArea().apply { isEditable = false caret.isVisible = false - caret.isSelectionVisible = false + caret.isSelectionVisible = true lineWrap = true wrapStyleWord = true foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 02d8db97b63..5320b21dbf0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.ui.SessionLayoutPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.openapi.Disposable @@ -23,6 +24,7 @@ class TurnView( private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, + private val selection: SessionSelection? = null, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -35,7 +37,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl) + val view = MessageView(msg, openFile, style, openUrl, selection) messages[msg.info.id] = view add(view) revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index e3ce38fb78a..09e234c84f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.views.base.GenericView import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.question.QuestionResultView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Generic @@ -21,32 +22,56 @@ import ai.kilocode.client.session.views.todo.TodoWriteView * 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error. */ object ViewFactory { + fun create( + content: Content, + openFile: (String) -> Unit, + ): PartView = create(content, openFile, openUrl = {}, selection = null) + + fun create( + content: Content, + openFile: (String) -> Unit, + openUrl: (String) -> Unit, + ): PartView = create(content, openFile, openUrl, selection = null) + fun create( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, + selection: SessionSelection? = null, ): PartView = when (content) { - is Text -> TextView(content, openUrl = openUrl) - is Reasoning -> ReasoningView(content, openUrl = openUrl) + is Text -> TextView(content, openUrl = openUrl, selection = selection) + is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection) is Tool -> when { TodoWriteView.canRender(content) -> TodoWriteView(content) - PlanExitView.canRender(content) -> PlanExitView(content, openFile) - QuestionResultView.canRender(content) -> QuestionResultView(content) - ReadToolView.canRender(content) -> ReadToolView(content, openFile) - else -> ToolView(content) + PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) + QuestionResultView.canRender(content) -> QuestionResultView(content, selection) + ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) + else -> ToolView(content, selection = selection) } is Compaction -> CompactionView(content) is StepFinish -> error("step-finish is timeline-only") is Generic -> GenericView(content) } + fun createUser( + content: Content, + openFile: (String) -> Unit, + ): PartView = createUser(content, openFile, openUrl = {}, selection = null) + + fun createUser( + content: Content, + openFile: (String) -> Unit, + openUrl: (String) -> Unit, + ): PartView = createUser(content, openFile, openUrl, selection = null) + fun createUser( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, + selection: SessionSelection? = null, ): PartView = when (content) { - is Text -> TextView(content, transparent = true, openUrl = openUrl) - else -> create(content, openFile, openUrl) + is Text -> TextView(content, transparent = true, openUrl = openUrl, selection = selection) + else -> create(content, openFile, openUrl, selection) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 15e27932ebc..d43a1aa6ae8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.RoundedContentPanel @@ -37,7 +38,9 @@ import javax.swing.JPanel * Call [setTopPanel], [setHeaderIcon], [setHeader], [setDescription], * [setContent], [setActions], or [setActionEnabled] to configure the card. */ -class BaseQuestionView : RoundedContentPanel( +class BaseQuestionView( + private val selection: SessionSelection? = null, +) : RoundedContentPanel( UiStyle.Gap.lg(), UiStyle.Gap.pad(), ), SessionEditorStyleTarget { @@ -352,6 +355,7 @@ class BaseQuestionView : RoundedContentPanel( alignmentX = Component.LEFT_ALIGNMENT } tracked.add(area to bold) + selection?.register(area) applyFont(area, bold) return area } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index b35fbb9f9c1..acb40df6dca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle @@ -17,6 +18,8 @@ import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.rpc.dto.PermissionReplyDto import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.ColorUtil import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration @@ -42,18 +45,20 @@ import javax.swing.text.html.StyleSheet */ class PermissionView( private val reply: (String, PermissionReplyDto) -> Unit, + private val selection: SessionSelection? = null, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { override val sessionViewKind = SessionView.Kind.Default private var requestId: String? = null private var style = SessionEditorStyle.current() - private val card = BaseQuestionView() + private val card = BaseQuestionView(selection) private val body = Stack.vertical() // Track target panes for style updates private val panes = mutableListOf() + private val regs = mutableListOf() private val diffViews = mutableListOf() private val ID_DENY = "deny" @@ -79,6 +84,7 @@ class PermissionView( card.setHeader(KiloBundle.message("session.permission.title")) body.removeAll() + disposeRegs() panes.clear() diffViews.clear() @@ -102,6 +108,7 @@ class PermissionView( fun hideView() { requestId = null body.removeAll() + disposeRegs() panes.clear() diffViews.clear() isVisible = false @@ -158,10 +165,11 @@ class PermissionView( }, ).apply { isEditable = false - isOpaque = true - this.text = "
${XmlStringUtil.escapeString(text)}
" - applyTargetPane(this) - } + isOpaque = true + this.text = "
${XmlStringUtil.escapeString(text)}
" + applyTargetPane(this) + selection?.register(this)?.let(regs::add) + } private fun applyTargetPane(pane: JBHtmlPane) { pane.font = style.transcriptFont @@ -244,6 +252,11 @@ class PermissionView( parent?.repaint() } + private fun disposeRegs() { + regs.forEach(Disposer::dispose) + regs.clear() + } + // Test helpers internal fun runButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.run") } internal fun denyButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.deny") } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index 9c0886aab36..32433623fe3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -3,12 +3,15 @@ package ai.kilocode.client.session.views.question import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.ToolView import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI @@ -25,13 +28,14 @@ import javax.swing.BoxLayout import javax.swing.JPanel import javax.swing.SwingUtilities -class QuestionResultView(tool: Tool) : PartView() { +class QuestionResultView(tool: Tool, private val selection: SessionSelection? = null) : PartView() { override val contentId: String = tool.id private var result = QuestionResultParser.parse(tool) ?: QuestionResult(emptyList(), emptyList()) private var style = SessionEditorStyle.current() private val texts = mutableListOf>() + private val regs = mutableListOf() private val root = object : JPanel(BorderLayout()) { override fun updateUI() { @@ -143,6 +147,11 @@ class QuestionResultView(tool: Tool) : PartView() { fun titleFont(): Font = title.font fun subFont(): Font = sub.font + override fun dispose() { + disposeRegs() + texts.clear() + } + override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})" companion object { @@ -179,6 +188,7 @@ class QuestionResultView(tool: Tool) : PartView() { private fun syncBody() { val panel = pane ?: return panel.removeAll() + disposeRegs() texts.clear() for ((i, q) in result.questions.withIndex()) { @@ -249,10 +259,16 @@ class QuestionResultView(tool: Tool) : PartView() { border = JBUI.Borders.empty() } texts.add(area to bold) + selection?.register(area)?.let(regs::add) setFont(area, bold) return area } + private fun disposeRegs() { + regs.forEach(Disposer::dispose) + regs.clear() + } + private fun syncArrow() { arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 1c8a77c28db..b0e6ecc8411 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -7,13 +7,16 @@ import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.editor.SessionEditorTextField import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel @@ -48,6 +51,7 @@ class QuestionView( private val reject: (String) -> Unit, private val follow: () -> Boolean = { true }, private val scroll: (Boolean) -> Unit = {}, + private val selection: SessionSelection? = null, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { override val sessionViewKind = SessionView.Kind.Default @@ -61,11 +65,12 @@ class QuestionView( private var customOpen = emptyList() private var style = SessionEditorStyle.current() private val texts = mutableListOf>() + private val regs = mutableListOf() // The custom editor for the currently shown question; null when not shown. private var customEditor: SessionEditorTextField? = null private var customFocus: FocusAdapter? = null - private val card = BaseQuestionView() + private val card = BaseQuestionView(selection) private val summary = JBLabel() private val nav = JPanel().apply { @@ -145,6 +150,7 @@ class QuestionView( customOpen = emptyList() customEditor = null customFocus = null + disposeRegs() texts.clear() body.removeAll() card.setActions(emptyList()) @@ -169,6 +175,7 @@ class QuestionView( @RequiresEdt private fun syncPage() { val q = question ?: return + disposeRegs() texts.clear() customEditor = null customFocus = null @@ -693,10 +700,16 @@ class QuestionView( border = JBUI.Borders.empty() } texts.add(area to bold) + selection?.register(area)?.let(regs::add) setFont(area, bold) return area } + private fun disposeRegs() { + regs.forEach(Disposer::dispose) + regs.clear() + } + private fun single(q: Question): Boolean = q.items.size == 1 && !q.items[0].multiple private fun review(q: Question): Boolean = !single(q) && idx == q.items.size diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 7b49c51d716..2ff6770974e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.openapi.Disposable import java.awt.Color import java.awt.Font @@ -15,6 +16,7 @@ interface MdView : Disposable { fun append(delta: String) fun clear() fun applyStyle(style: SessionEditorStyle) + fun setSelection(selection: SessionSelection?) fun resetStyles() fun addLinkListener(listener: LinkListener) fun removeLinkListener(listener: LinkListener) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt index e2a5d4c9cdc..259247aeb68 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt @@ -1,11 +1,15 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection object MdViewFactory { - fun create(style: SessionEditorStyle = SessionEditorStyle.current()): MdView = hybrid(style) + fun create(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = + hybrid(style, selection) - fun hybrid(style: SessionEditorStyle = SessionEditorStyle.current()): MdView = MdViewHybrid(style) + fun hybrid(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = + MdViewHybrid(style, selection) - fun html(style: SessionEditorStyle = SessionEditorStyle.current()): MdView = MdViewHtmlPane(style) + fun html(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = + MdViewHtmlPane(style, selection) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt index 7644da58530..cfc41fed0d1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt @@ -1,7 +1,10 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.log.KiloLog +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration import com.intellij.ui.components.JBHtmlPaneStyleConfiguration @@ -19,6 +22,7 @@ import javax.swing.text.html.StyleSheet @Suppress("UnstableApiUsage") internal class MdViewHtmlPane( style: SessionEditorStyle = SessionEditorStyle.current(), + private var selection: SessionSelection? = null, ) : MdView { companion object { private val LOG = KiloLog.create(MdViewHtmlPane::class.java) @@ -28,6 +32,7 @@ internal class MdViewHtmlPane( private val source = StringBuilder() private var rendered = "" private var style = style + private var reg: Disposable? = null private var disposed = false private val extensions = listOf( @@ -78,6 +83,10 @@ internal class MdViewHtmlPane( } } + init { + syncSelection() + } + override val component: JComponent get() = pane override var font: Font @@ -195,10 +204,20 @@ internal class MdViewHtmlPane( if (disposed) return if (this.style == style) return this.style = style + selection?.applyStyle(style) if (opaqueState) pane.background = background markDirty() } + override fun setSelection(selection: SessionSelection?) { + if (disposed) return + if (this.selection === selection) return + reg?.let(Disposer::dispose) + reg = null + this.selection = selection + syncSelection() + } + override fun resetStyles() { if (disposed) return fontOverride = null @@ -266,9 +285,15 @@ internal class MdViewHtmlPane( listeners.clear() source.clear() rendered = "" + reg?.let(Disposer::dispose) + reg = null pane.text = "" } + private fun syncSelection() { + reg = selection?.register(pane) + } + private fun dispatch(event: MdView.LinkEvent) { for (l in listeners) l.onLink(event) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index 302ad0a8013..ef5c402103c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.log.KiloLog import com.intellij.openapi.Disposable @@ -42,6 +43,7 @@ import javax.swing.text.html.StyleSheet @Suppress("UnstableApiUsage") internal class MdViewHybrid( style: SessionEditorStyle = SessionEditorStyle.current(), + private var selection: SessionSelection? = null, ) : MdView { companion object { private val LOG = KiloLog.create(MdViewHybrid::class.java) @@ -274,9 +276,17 @@ internal class MdViewHybrid( if (disposed) return if (this.style == style) return this.style = style + selection?.applyStyle(style) syncStyle() } + override fun setSelection(selection: SessionSelection?) { + if (disposed) return + if (this.selection === selection) return + this.selection = selection + syncBlocks() + } + override fun resetStyles() { if (disposed) return fontOverride = null @@ -412,6 +422,7 @@ internal class MdViewHybrid( isOpaque = opts.opaque background = opts.background text = "$body" + block?.let { selection?.register(this, it) } addHyperlinkListener { e -> if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener val href = e.description ?: return@addHyperlinkListener @@ -427,6 +438,7 @@ internal class MdViewHybrid( val field = runCatching { CodeField(file(lang), opts, text).also { ed -> block?.let { ed.setDisposedWith(it) } + block?.let { selection?.register(ed, it) } } }.getOrElse { err -> LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) @@ -502,6 +514,7 @@ internal class MdViewHybrid( SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, ) + block?.let { selection?.register(this, it) } } private inner class CodeField(file: FileType, opts: MdStyle, value: String) : diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt new file mode 100644 index 00000000000..0c64f021c9f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -0,0 +1,125 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.SessionUiTestBase +import ai.kilocode.client.session.views.ToolView +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.ide.CopyProvider +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DataKey +import com.intellij.openapi.actionSystem.DataMap +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DataSnapshotProvider +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.ide.CopyPasteManager +import java.awt.Container +import java.awt.datatransfer.DataFlavor +import javax.swing.text.JTextComponent + +@Suppress("UnstableApiUsage") +class SessionSelectionCopyTest : SessionUiTestBase() { + fun `test session ui exposes copy provider when selection exists`() { + val area = showTool("alpha output") + + select(area, "alpha") + val provider = copyProvider() + + assertNotNull(provider) + assertTrue(provider!!.isCopyEnabled(DataContext.EMPTY_CONTEXT)) + } + + fun `test copy provider writes active selected text`() { + val area = showTool("alpha output") + + select(area, "alpha") + copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test selecting another transcript component changes copied text`() { + val one = showTool("alpha output", id = "tool_a") + val two = showTool("bravo output", id = "tool_b") + + select(one, "alpha") + select(two, "bravo") + copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertNull(one.selectedText) + assertEquals("bravo", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + private fun select(area: JTextComponent, text: String) { + val start = area.text.indexOf(text) + assertTrue(start >= 0) + area.select(start, start + text.length) + } + + private fun showTool(text: String, id: String = "tool_msg"): JTextComponent { + if (controller().id == null) showMessages() + emit(ChatEventDto.MessageUpdated("ses_test", message(id))) + emit(ChatEventDto.PartUpdated( + "ses_test", + PartDto( + id = "part_$id", + sessionID = "ses_test", + messageID = id, + type = "tool", + tool = "bash", + state = "completed", + input = mapOf("command" to "printf"), + output = text, + ), + )) + for (view in toolViews(ui)) view.expand() + layout() + return textComponent(text) + } + + private fun toolViews(root: Container): List { + val out = mutableListOf() + if (root is ToolView) out.add(root) + for (child in root.components) { + if (child is Container) out.addAll(toolViews(child)) + } + return out + } + + private fun copyProvider(): CopyProvider? { + val sink = CopySink() + (ui as UiDataProvider).uiDataSnapshot(sink) + return sink.copy + } + + private fun textComponent(needle: String): JTextComponent = textComponents(ui) + .first { it.text.contains(needle) } + + private fun textComponents(root: Container): List { + val out = mutableListOf() + if (root is JTextComponent) out.add(root) + for (child in root.components) { + if (child is Container) out.addAll(textComponents(child)) + } + return out + } + + private class CopySink : DataSink { + var copy: CopyProvider? = null + + override fun set(key: DataKey, data: T?) { + if (key == PlatformDataKeys.COPY_PROVIDER) copy = data as? CopyProvider + } + + override fun setNull(key: DataKey) {} + + override fun lazyNull(key: DataKey) {} + + override fun lazyValue(key: DataKey, data: (DataMap) -> T?) {} + + override fun uiDataSnapshot(provider: UiDataProvider) = provider.uiDataSnapshot(this) + override fun dataSnapshot(provider: DataSnapshotProvider) = provider.dataSnapshot(this) + override fun uiDataSnapshot(provider: DataProvider) {} + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt new file mode 100644 index 00000000000..f3d6736a982 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt @@ -0,0 +1,72 @@ +package ai.kilocode.client.session.ui.selection + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextArea +import java.awt.event.MouseEvent + +@Suppress("UnstableApiUsage") +class SessionSelectionTest : BasePlatformTestCase() { + fun `test selecting second text component clears first`() { + val selection = SessionSelection() + val one = JBTextArea("first value") + val two = JBTextArea("second value") + selection.register(one) + selection.register(two) + + one.select(0, 5) + two.select(0, 6) + + assertNull(one.selectedText) + assertEquals("second", two.selectedText) + assertEquals("second", selection.selectedText()) + } + + fun `test clearing active selection disables copy text`() { + val selection = SessionSelection() + val area = JBTextArea("selected value") + selection.register(area) + + area.select(0, 8) + area.select(0, 0) + + assertNull(selection.selectedText()) + } + + fun `test starting mouse selection clears previous text component immediately`() { + val selection = SessionSelection() + val one = JBTextArea("first value") + val two = JBTextArea("second value") + selection.register(one) + selection.register(two) + + one.select(0, 5) + val event = MouseEvent(two, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), 0, 1, 1, 1, false) + for (listener in two.mouseListeners) listener.mousePressed(event) + + assertNull(one.selectedText) + assertNull(selection.selectedText()) + } + + fun `test unregistering active selection clears active state`() { + val selection = SessionSelection() + val area = JBTextArea("selected value") + val reg = selection.register(area) + + area.select(0, 8) + Disposer.dispose(reg) + + assertNull(selection.selectedText()) + } + + fun `test applyStyle updates swing selection colors`() { + val selection = SessionSelection() + val area = JBTextArea("selected value") + selection.register(area) + selection.applyStyle(SessionEditorStyle.current()) + + assertNotNull(area.selectionColor) + assertNotNull(area.selectedTextColor) + } +} From ede4369ba39b3a3bcb803bb3a1c33889581237dc Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 13:00:40 -0400 Subject: [PATCH 059/201] fix(kilo-jetbrains): show settings progress as overlay --- .../client/session/ui/SessionRootPanel.kt | 158 +------------ .../settings/models/ModelsConfigurable.kt | 3 +- .../settings/models/ModelsSettingsUi.kt | 209 ++++++++---------- .../profile/UserProfileConfigurable.kt | 19 +- .../settings/ui/SettingsContentPanel.kt | 23 ++ .../client/settings/ui/SettingsPanel.kt | 51 +++++ .../settings/ui/SettingsProgressOverlay.kt | 67 ++++++ .../client/settings/ui/SettingsRow.kt | 87 ++++++-- .../client/settings/ui/SettingsTop.kt | 89 ++++++++ .../kilocode/client/ui/LayeredOverlayPanel.kt | 158 +++++++++++++ .../resources/messages/KiloBundle.properties | 2 + .../client/settings/ui/SettingsRowsTest.kt | 145 ++++++++++++ .../client/ui/LayeredOverlayPanelTest.kt | 116 ++++++++++ 13 files changed, 840 insertions(+), 287 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt index 2c962db13b0..ba03b2f1bb4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -1,156 +1,16 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.layout.HAlign -import ai.kilocode.client.ui.layout.VAlign -import ai.kilocode.client.ui.layout.align -import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.util.ui.JBDimension -import com.intellij.util.ui.components.BorderLayoutPanel -import java.awt.BorderLayout -import java.awt.Container -import java.awt.Dimension -import java.awt.Rectangle -import javax.swing.JComponent -import javax.swing.JLayeredPane -import javax.swing.JPanel +import ai.kilocode.client.ui.LayeredOverlayPanel -class SessionRootPanel : JLayeredPane() { +class SessionRootPanel( + private val sessionOverlay: Overlay = Overlay(), + private val sessionBlocker: Blocker = Blocker(), +) : LayeredOverlayPanel(overlay = sessionOverlay, blocker = sessionBlocker) { + override val overlay: Overlay get() = sessionOverlay - val content: JPanel = BorderLayoutPanel() + override val blocker: Blocker get() = sessionBlocker - val overlay = Overlay() + class Overlay : LayeredOverlayPanel.Overlay() - val blocker = Blocker() - - init { - layout = null - add(content) - setLayer(content, DEFAULT_LAYER) - add(overlay) - setLayer(overlay, PALETTE_LAYER) - add(blocker) - setLayer(blocker, MODAL_LAYER) - blocker.isVisible = false - } - - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { - overlay.addOverlay(child, bounds) - } - - @RequiresEdt - fun setModalContent(child: JComponent?) { - blocker.removeAll() - if (child != null) blocker.add(child.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) - blocker.isVisible = child != null - if (child != null) blocker.requestFocusInWindow() - invalidate() - blocker.invalidate() - child?.invalidate() - if (width > 0 && height > 0) { - doLayout() - child?.let(::layoutTree) - } - blocker.revalidate() - blocker.repaint() - revalidate() - repaint() - } - - @RequiresEdt - fun setBlocked(value: Boolean) { - blocker.isVisible = value - if (value) blocker.requestFocusInWindow() - invalidate() - blocker.invalidate() - if (width > 0 && height > 0) doLayout() - revalidate() - repaint() - } - - override fun doLayout() { - components - .sortedBy { getLayer(it) } - .forEach { child -> - child.setBounds(0, 0, width, height) - child.doLayout() - } - } - - override fun getPreferredSize(): Dimension { - // Only content and overlay contribute to preferred size; blocker is invisible by default. - val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0 - val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0 - return JBDimension(w, h) - } - - class Overlay : BorderLayoutPanel() { - - private val items = linkedMapOf Rectangle>() - - init { - layout = null - // The overlay must let mouse events fall through outside visible children. - isOpaque = false - } - - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { - items[child] = bounds - add(child) - } - - override fun contains(x: Int, y: Int): Boolean { - for (child in components) { - if (child.isVisible && child.bounds.contains(x, y)) return true - } - return false - } - - override fun doLayout() { - items.forEach { (child, bounds) -> - child.bounds = bounds(this, child) - child.doLayout() - } - } - - override fun getPreferredSize(): Dimension { - val pref = super.getPreferredSize() - val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0) - val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0) - return JBDimension(w, h) - } - } - - /** - * Full-area blocking overlay rendered above the scroll overlay at MODAL_LAYER. - * When visible: consumes all mouse events across the full panel area. - * When hidden: passes all mouse events through (isVisible=false). - */ - class Blocker : JPanel() { - init { - layout = BorderLayout() - isFocusable = true - } - - override fun updateUI() { - super.updateUI() - background = UiStyle.Colors.bg() - isOpaque = true - } - - override fun contains(x: Int, y: Int): Boolean { - if (!isVisible) return false - return super.contains(x, y) - } - - override fun doLayout() { - super.doLayout() - components.forEach { layoutTree(it) } - } - } -} - -private fun layoutTree(comp: java.awt.Component) { - comp.doLayout() - if (comp is Container) comp.components.forEach { layoutTree(it) } + class Blocker : LayeredOverlayPanel.Blocker() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt index 6045f58c3f5..50e2e784977 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings.models import ai.kilocode.client.plugin.KiloBundle import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.options.Configurable import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.options.SearchableConfigurable import kotlinx.coroutines.CoroutineScope @@ -11,7 +12,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import javax.swing.JComponent -class ModelsConfigurable : SearchableConfigurable { +class ModelsConfigurable : SearchableConfigurable, Configurable.NoScroll { private var ui: ModelsSettingsUi? = null private var scope: CoroutineScope? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index 77327d63bbe..19544d82447 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -8,11 +8,12 @@ import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.model.ModelText import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.settings.profile.edt +import ai.kilocode.client.settings.ui.SettingsBannerKind +import ai.kilocode.client.settings.ui.SettingsContentPanel +import ai.kilocode.client.settings.ui.SettingsPanel import ai.kilocode.client.settings.ui.SettingsRow import ai.kilocode.client.settings.ui.SettingsRows -import ai.kilocode.client.ui.UiStyle 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.rpc.dto.AgentDto @@ -29,48 +30,29 @@ import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.options.ex.Settings import com.intellij.openapi.project.ProjectManager -import com.intellij.ui.EditorNotificationPanel -import com.intellij.ui.InlineBanner -import com.intellij.ui.TitledSeparator -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.awt.BorderLayout import java.util.function.Predicate import javax.swing.JComponent -import javax.swing.JPanel internal class ModelsSettingsUi( private val cs: CoroutineScope, private val app: KiloAppService = service(), private val workspaces: KiloWorkspaceService = service(), private val directory: String? = null, -) : JPanel(BorderLayout()) { +) : SettingsPanel() { - private val status = JBLabel(KiloBundle.message("settings.models.loading")) - private val banner = InlineBanner( - KiloBundle.message("settings.models.login.message"), - EditorNotificationPanel.Status.Warning, - ).showCloseButton(false) - private val content = Stack.vertical() - private val rows = SettingsRows() - private val modes = SettingsRows() - private val defaults = ModelSettingPicker() - private val small = ModelSettingPicker() - private val subagent = ModelSettingPicker() - private val variant = ReasoningPicker() - private val variantRow = SettingsRow( - KiloBundle.message("settings.models.subagentVariant.title"), - KiloBundle.message("settings.models.subagentVariant.description"), - variant.align(HAlign.RIGHT, VAlign.CENTER), - ) - private val pickers = mutableMapOf() + private val form = ModelsSettingsContent(app, ::update, ::selectSubagent) + private val defaults = form.defaults + private val small = form.small + private val subagent = form.subagent + private val variant = form.variant + private val variantRow = form.variantRow + private val pickers = form.pickers private val jobs = mutableListOf() private var baseline = ModelsDraft() @@ -87,50 +69,7 @@ internal class ModelsSettingsUi( private var pending: ModelsDraft? = null init { - status.foreground = UIUtil.getContextHelpForeground() - defaults.picker.onSelect = { update { copy(model = it.key) } } - defaults.picker.onClear = { update { copy(model = null) } } - small.picker.onSelect = { update { copy(small = it.key) } } - small.picker.onClear = { update { copy(small = null) } } - small.picker.includeSmall = true - subagent.picker.onSelect = { item -> selectSubagent(item) } - subagent.picker.onClear = { update { copy(subagent = null, variant = null) } } - variant.onSelect = { item -> update { copy(variant = item.id) } } - listOf(defaults, small, subagent).forEach { picker -> - picker.picker.favorites = { app.favorites.value } - picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } - } - - banner.addAction(KiloBundle.message("settings.models.login.action"), Runnable { openProfile(banner) }) - content.next(banner) - content.gap(UiStyle.Gap.md()) - content.next(status) - content.gap(UiStyle.Gap.lg()) - content.next(TitledSeparator(KiloBundle.message("settings.models.displayName"))) - rows.row(SettingsRow( - KiloBundle.message("settings.models.defaultModel.title"), - KiloBundle.message("settings.models.defaultModel.description"), - defaults, - )) - rows.row(SettingsRow( - KiloBundle.message("settings.models.smallModel.title"), - KiloBundle.message("settings.models.smallModel.description"), - small, - )) - rows.row(SettingsRow( - KiloBundle.message("settings.models.subagentModel.title"), - KiloBundle.message("settings.models.subagentModel.description"), - subagent, - )) - rows.row(variantRow) - content.next(rows) - content.next(TitledSeparator(KiloBundle.message("settings.models.modeModels.title"))) - content.next(JBLabel(KiloBundle.message("settings.models.modeModels.description")).apply { - foreground = UIUtil.getContextHelpForeground() - font = UiStyle.Fonts.small() - }) - content.next(modes) - add(JBScrollPane(content).apply { border = null }, BorderLayout.CENTER) + setContent(form) sync() start() } @@ -145,7 +84,7 @@ internal class ModelsSettingsUi( fun resetDraft() { checkEdt() draft = baseline - if (!saving) status.text = "" + if (!saving) clearProgress() sync() } @@ -158,8 +97,7 @@ internal class ModelsSettingsUi( if (patch.values.isEmpty() && patch.agents.isEmpty()) return pending = next saving = true - status.text = KiloBundle.message("settings.models.save.pending") - status.foreground = UIUtil.getContextHelpForeground() + showProgress(KiloBundle.message("settings.models.save.pending")) sync() cs.launch { val state = app.updateConfig(patch) @@ -171,7 +109,7 @@ internal class ModelsSettingsUi( draft = next pending = null saving = false - status.text = "" + clearProgress() sync() return@withContext } @@ -179,8 +117,7 @@ internal class ModelsSettingsUi( draft = next pending = null saving = false - status.text = KiloBundle.message("settings.models.save.failed") - status.foreground = UiStyle.Colors.errorLabelForeground() + clearProgress() sync() } } @@ -285,40 +222,28 @@ internal class ModelsSettingsUi( ready = appState.status == KiloAppStatusDto.READY, authenticated = appState.profile != null, ) - val structural = banner.isVisible != bannerVisible - banner.isVisible = bannerVisible - if (!saving) { + if (bannerVisible) { + top.showNotLoggedIn { openProfile(it) } + } else { when (state) { - ModelsStatus.UNAVAILABLE -> { - status.text = KiloBundle.message("settings.models.unavailable") - status.foreground = UIUtil.getContextHelpForeground() - } - ModelsStatus.LOADING -> { - status.text = KiloBundle.message("settings.models.loading") - status.foreground = UIUtil.getContextHelpForeground() - } - ModelsStatus.LOAD_FAILED -> { - status.text = KiloBundle.message("settings.models.load.failed") - status.foreground = UiStyle.Colors.errorLabelForeground() - } - ModelsStatus.NO_PROVIDERS -> { - status.text = KiloBundle.message("settings.models.noProviders") - status.foreground = UIUtil.getContextHelpForeground() - } - ModelsStatus.MODES_FAILED -> { - status.text = KiloBundle.message("settings.models.modes.failed") - status.foreground = UiStyle.Colors.warningLabelForeground() - } - ModelsStatus.READY, - ModelsStatus.SAVING -> { - status.text = "" - status.foreground = UIUtil.getContextHelpForeground() - } + ModelsStatus.LOAD_FAILED -> top.showBanner( + KiloBundle.message("settings.models.load.failed"), + emptyList(), + SettingsBannerKind.ERROR, + ) + ModelsStatus.NO_PROVIDERS -> top.showBanner(KiloBundle.message("settings.models.noProviders"), emptyList()) + ModelsStatus.MODES_FAILED -> top.showBanner(KiloBundle.message("settings.models.modes.failed"), emptyList()) + else -> top.hideBanner() } } - val statusVisible = status.text.isNotBlank() - var layout = structural || status.isVisible != statusVisible - status.isVisible = statusVisible + if (saving || state == ModelsStatus.SAVING) { + showProgress(KiloBundle.message("settings.models.save.pending")) + } else if (state == ModelsStatus.UNAVAILABLE || state == ModelsStatus.LOADING) { + showProgress(KiloBundle.message("settings.models.loading")) + } else { + clearProgress() + } + var layout = false defaults.setItems(allItems, draft.model) small.setItems(smallItems, draft.small) subagent.setItems(allItems, draft.subagent) @@ -364,7 +289,7 @@ internal class ModelsSettingsUi( var layout = false val names = agents.map { it.name } if (names != pickers.keys.toList()) { - modes.removeAll() + form.modes.removeAll() pickers.clear() agents.forEach { agent -> val picker = ModelSettingPicker() @@ -373,7 +298,7 @@ internal class ModelsSettingsUi( picker.picker.onSelect = { item -> update { copy(agents = this.agents + (agent.name to item.key)) } } picker.picker.onClear = { update { copy(agents = this.agents + (agent.name to null)) } } pickers[agent.name] = picker - modes.row(SettingsRow( + form.modes.row(agent.name, SettingsRow( agent.displayName ?: title(agent.name), agent.description, picker, @@ -381,7 +306,10 @@ internal class ModelsSettingsUi( } layout = true } - for ((name, picker) in pickers) { + agents.forEach { agent -> + val name = agent.name + val picker = pickers[name] ?: return@forEach + form.modes.update(name, agent.displayName ?: title(name), agent.description, picker) val value = draft.agents[name] picker.setItems(allItems, value) picker.isEnabled = ready @@ -441,6 +369,61 @@ internal class ModelsSettingsUi( private const val KILO_PROVIDER = "kilo" +private class ModelsSettingsContent( + app: KiloAppService, + update: (ModelsDraft.() -> ModelsDraft) -> Unit, + select: (ModelPicker.Item) -> Unit, +) : SettingsContentPanel() { + val defaults = ModelSettingPicker() + val small = ModelSettingPicker() + val subagent = ModelSettingPicker() + val variant = ReasoningPicker() + val variantRow = SettingsRow( + KiloBundle.message("settings.models.subagentVariant.title"), + KiloBundle.message("settings.models.subagentVariant.description"), + variant.align(HAlign.RIGHT, VAlign.CENTER), + ) + val modes: SettingsRows + val pickers = linkedMapOf() + + init { + defaults.picker.onSelect = { update { copy(model = it.key) } } + defaults.picker.onClear = { update { copy(model = null) } } + small.picker.onSelect = { update { copy(small = it.key) } } + small.picker.onClear = { update { copy(small = null) } } + small.picker.includeSmall = true + subagent.picker.onSelect = { item -> select(item) } + subagent.picker.onClear = { update { copy(subagent = null, variant = null) } } + variant.onSelect = { item -> update { copy(variant = item.id) } } + listOf(defaults, small, subagent).forEach { picker -> + picker.picker.favorites = { app.favorites.value } + picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } + } + + val rows = section(KiloBundle.message("settings.models.displayName")) + rows.row(SettingsRow( + KiloBundle.message("settings.models.defaultModel.title"), + KiloBundle.message("settings.models.defaultModel.description"), + defaults, + )) + rows.row(SettingsRow( + KiloBundle.message("settings.models.smallModel.title"), + KiloBundle.message("settings.models.smallModel.description"), + small, + )) + rows.row(SettingsRow( + KiloBundle.message("settings.models.subagentModel.title"), + KiloBundle.message("settings.models.subagentModel.description"), + subagent, + )) + rows.row(variantRow) + modes = section( + KiloBundle.message("settings.models.modeModels.title"), + KiloBundle.message("settings.models.modeModels.description"), + ) + } +} + private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } private fun title(value: String): String = value.replace('-', ' ').replace('_', ' ').replaceFirstChar { it.titlecase() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt index 70cc62339e7..44cfd7a1c38 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt @@ -2,9 +2,11 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.ui.SettingsPanel import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service +import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.wm.IdeFocusManager import kotlinx.coroutines.CoroutineScope @@ -25,9 +27,10 @@ import javax.swing.JComponent * and a link to the Kilo dashboard. This is a status/action panel — it * has no persistent settings, so [isModified] always returns false. */ -class UserProfileConfigurable : SearchableConfigurable { +class UserProfileConfigurable : SearchableConfigurable, Configurable.NoScroll { - private var ui: JComponent? = null + private var ui: ProfileUi? = null + private var shell: SettingsPanel? = null private var scope: CoroutineScope? = null private var watchJob: Job? = null private var focus = false @@ -36,12 +39,12 @@ class UserProfileConfigurable : SearchableConfigurable { override fun getDisplayName(): String = KiloBundle.message("settings.profile.displayName") - override fun getPreferredFocusedComponent(): JComponent? = (ui as? ProfileUi)?.preferredFocus() + override fun getPreferredFocusedComponent(): JComponent? = ui?.preferredFocus() override fun focusOn(label: String) { if (label != FOCUS_ACCOUNT_COMBO) return focus = true - val panel = ui as? ProfileUi ?: return + val panel = ui ?: return requestFocus(panel) } @@ -49,10 +52,13 @@ class UserProfileConfigurable : SearchableConfigurable { val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default) scope = cs val panel = buildPanel(cs) + val root = SettingsPanel() + root.setContent(panel) ui = panel + shell = root startWatching(cs, panel) if (focus) requestFocus(panel) - return panel + return root } private fun requestFocus(panel: ProfileUi) { @@ -93,10 +99,11 @@ class UserProfileConfigurable : SearchableConfigurable { override fun disposeUIResources() { // Dispose UI first to invalidate pending login attempts before scope cancellation. // Capturing local refs before nulling fields so the EDT callback is self-contained. - val panel = ui as? ProfileUi + val panel = ui val job = watchJob val cs = scope ui = null + shell = null watchJob = null scope = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt new file mode 100644 index 00000000000..9a661d69922 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.settings.ui + +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.ui.TitledSeparator +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil + +internal open class SettingsContentPanel : Stack(StackAxis.VERTICAL) { + fun section(title: String, description: String? = null): SettingsRows { + next(TitledSeparator(title)) + if (description != null) { + next(JBLabel(description).apply { + foreground = UIUtil.getContextHelpForeground() + font = UiStyle.Fonts.small() + }) + } + val rows = SettingsRows() + next(rows) + return rows + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt new file mode 100644 index 00000000000..2e5293a482d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt @@ -0,0 +1,51 @@ +package ai.kilocode.client.settings.ui + +import ai.kilocode.client.ui.LayeredOverlayPanel +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.ui.components.JBScrollPane +import java.awt.BorderLayout +import java.awt.Rectangle +import javax.swing.JComponent + +internal open class SettingsPanel : LayeredOverlayPanel() { + val top = SettingsTop() + val settings = Stack.vertical() + val progress = SettingsProgressOverlay() + + init { + val body = Stack.vertical() + .next(top) + .gap(UiStyle.Gap.lg()) + .next(settings) + content.add(JBScrollPane(body).apply { border = null }, BorderLayout.CENTER) + addOverlay(progress) { pane, child -> + val size = child.preferredSize + Rectangle( + ((pane.width - size.width) / 2).coerceAtLeast(0), + UiStyle.Gap.pad(), + size.width, + size.height, + ) + } + } + + fun setContent(component: JComponent) { + settings.removeAll() + settings.next(component) + revalidate() + repaint() + } + + fun showProgress(text: String) { + progress.showProgress(text) + overlay.revalidate() + overlay.repaint() + } + + fun clearProgress() { + progress.clearProgress() + overlay.revalidate() + overlay.repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt new file mode 100644 index 00000000000..cff8c3b95eb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt @@ -0,0 +1,67 @@ +package ai.kilocode.client.settings.ui + +import ai.kilocode.client.ui.UiStyle +import com.intellij.codeInsight.hint.HintUtil +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.JPanel + +internal class SettingsProgressOverlay : JPanel(BorderLayout()) { + private var label: JBLabel? = null + + init { + val view = JBLabel() + label = view + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad()) + add(view, BorderLayout.CENTER) + isVisible = false + syncColors() + } + + fun showProgress(text: String) { + val view = requireNotNull(label) + if (view.text != text) view.text = text + if (!isVisible) isVisible = true + revalidate() + repaint() + } + + fun clearProgress() { + val view = requireNotNull(label) + if (!isVisible && view.text.isNullOrBlank()) return + view.text = "" + isVisible = false + revalidate() + repaint() + } + + override fun updateUI() { + super.updateUI() + syncColors() + } + + private fun syncColors() { + val hint = HintUtil.getInformationHint() + background = hint.textBackground + foreground = hint.textForeground + label?.foreground = foreground + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = background + val arc = UiStyle.Arc.component() + g2.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + g2.dispose() + } + super.paintComponent(g) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt index 9ad34bc37a1..9a37e556629 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt @@ -6,8 +6,6 @@ import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align -import com.intellij.ui.JBColor -import com.intellij.ui.SeparatorComponent import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -18,38 +16,91 @@ import javax.swing.JPanel class SettingsRow( title: String, description: String? = null, - value: JComponent, + value: JComponent? = null, ) : JPanel(BorderLayout()) { + private val titleLabel = JBLabel(title).apply { font = UiStyle.Fonts.bold() } + private val descriptionLabel = JBLabel(description.orEmpty()).apply { + font = UiStyle.Fonts.hint() + foreground = UIUtil.getContextHelpForeground() + isVisible = description != null + } + private val labels = Stack.vertical(UiStyle.Gap.sm()) + private val valuePanel = JPanel(BorderLayout()) + private var current: JComponent? = null + init { border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, UiStyle.Gap.pad(), 0) - - val labels = Stack.vertical(UiStyle.Gap.sm()) - labels.next(JBLabel(title).apply { font = UiStyle.Fonts.bold() }) - if (description != null) { - labels.next(JBLabel(description).apply { - font = UiStyle.Fonts.hint() - foreground = UIUtil.getContextHelpForeground() - }) - } - + valuePanel.isOpaque = false + labels.next(titleLabel) + labels.next(descriptionLabel) add(labels, BorderLayout.CENTER) - add(value.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST) + add(valuePanel, BorderLayout.EAST) + setValue(value) + } + + fun update( + title: String, + description: String? = null, + value: JComponent? = null, + ) { + if (titleLabel.text != title) titleLabel.text = title + val text = description.orEmpty() + if (descriptionLabel.text != text) descriptionLabel.text = text + val visible = description != null + if (descriptionLabel.isVisible != visible) descriptionLabel.isVisible = visible + setValue(value) + } + + private fun setValue(value: JComponent?) { + if (value != null && current !== value) { + valuePanel.removeAll() + valuePanel.add(value.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.CENTER) + current = value + } } } class SettingsRows : Stack(StackAxis.VERTICAL) { - private var rows = 0 + private val keyed = linkedMapOf() fun row(child: SettingsRow): SettingsRows { - if (rows > 0) next(SeparatorComponent(0, JBColor.border(), null)) next(child) - rows += 1 return this } + fun row(key: String, child: SettingsRow): SettingsRow { + keyed.remove(key)?.let { remove(it) } + keyed[key] = child + next(child) + return child + } + + fun update( + key: String, + title: String, + description: String? = null, + value: JComponent? = null, + ): SettingsRow? { + val row = keyed[key] ?: return null + row.update(title, description, value) + return row + } + + fun remove(key: String): SettingsRow? { + val row = keyed.remove(key) ?: return null + remove(row) + revalidate() + repaint() + return row + } + + fun retain(keys: Set) { + keyed.keys.toList().filter { it !in keys }.forEach { remove(it) } + } + override fun removeAll() { - rows = 0 + keyed.clear() super.removeAll() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt new file mode 100644 index 00000000000..e34c7a8b01b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.settings.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.InlineBanner +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +internal data class SettingsAction( + val text: String, + val run: (JComponent) -> Unit, +) + +internal enum class SettingsBannerKind { WARNING, ERROR } + +internal class SettingsTop : Stack(StackAxis.VERTICAL, UiStyle.Gap.md()) { + private val slot = JPanel(BorderLayout()) + private var spec: BannerSpec? = null + + init { + slot.isOpaque = false + slot.isVisible = false + next(slot) + isVisible = false + } + + fun showBanner( + text: String, + actions: List, + kind: SettingsBannerKind = SettingsBannerKind.WARNING, + ) { + val next = BannerSpec(text, actions.map { it.text }, kind) + if (spec != next) { + val banner = InlineBanner(text, status(kind)).showCloseButton(false) + actions.forEach { action -> + banner.addAction(action.text, Runnable { action.run(banner) }) + } + slot.removeAll() + slot.add(banner, BorderLayout.CENTER) + spec = next + } + show(slot) + } + + fun showNotLoggedIn(run: (JComponent) -> Unit) { + showBanner( + KiloBundle.message("settings.login.message"), + listOf(SettingsAction(KiloBundle.message("settings.login.action"), run)), + ) + } + + fun hideBanner() { + if (!slot.isVisible) return + slot.isVisible = false + sync(layout = true) + } + + private fun show(component: JComponent) { + if (component.isVisible) { + sync(layout = false) + return + } + component.isVisible = true + sync(layout = true) + } + + private fun sync(layout: Boolean) { + val visible = slot.isVisible + val changed = isVisible != visible + isVisible = visible + if (changed || layout) parent?.revalidate() + parent?.repaint() + } + + private fun status(kind: SettingsBannerKind) = when (kind) { + SettingsBannerKind.WARNING -> EditorNotificationPanel.Status.Warning + SettingsBannerKind.ERROR -> EditorNotificationPanel.Status.Error + } + + private data class BannerSpec( + val text: String, + val actions: List, + val kind: SettingsBannerKind, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt new file mode 100644 index 00000000000..311bee99c90 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -0,0 +1,158 @@ +package ai.kilocode.client.ui + +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Container +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JComponent +import javax.swing.JLayeredPane +import javax.swing.JPanel + +open class LayeredOverlayPanel( + content: JPanel = BorderLayoutPanel(), + overlay: Overlay = Overlay(), + blocker: Blocker = Blocker(), +) : JLayeredPane() { + + private val baseContent = content + + private val baseOverlay = overlay + + private val baseBlocker = blocker + + open val content: JPanel get() = baseContent + + open val overlay: Overlay get() = baseOverlay + + open val blocker: Blocker get() = baseBlocker + + init { + layout = null + add(baseContent) + setLayer(baseContent, DEFAULT_LAYER) + add(baseOverlay) + setLayer(baseOverlay, PALETTE_LAYER) + add(baseBlocker) + setLayer(baseBlocker, MODAL_LAYER) + baseBlocker.isVisible = false + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + overlay.addOverlay(child, bounds) + } + + @RequiresEdt + fun setModalContent(child: JComponent?) { + blocker.removeAll() + if (child != null) blocker.add(child.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) + blocker.isVisible = child != null + if (child != null) blocker.requestFocusInWindow() + invalidate() + blocker.invalidate() + child?.invalidate() + if (width > 0 && height > 0) { + doLayout() + child?.let(::layoutTree) + } + blocker.revalidate() + blocker.repaint() + revalidate() + repaint() + } + + @RequiresEdt + fun setBlocked(value: Boolean) { + blocker.isVisible = value + if (value) blocker.requestFocusInWindow() + invalidate() + blocker.invalidate() + if (width > 0 && height > 0) doLayout() + revalidate() + repaint() + } + + override fun doLayout() { + components + .sortedBy { getLayer(it) } + .forEach { child -> + child.setBounds(0, 0, width, height) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0 + val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0 + return JBDimension(w, h) + } + + open class Overlay : BorderLayoutPanel() { + + private val items = linkedMapOf Rectangle>() + + init { + layout = null + isOpaque = false + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + items[child] = bounds + add(child) + } + + override fun contains(x: Int, y: Int): Boolean { + for (child in components) { + if (child.isVisible && child.bounds.contains(x, y)) return true + } + return false + } + + override fun doLayout() { + items.forEach { (child, bounds) -> + child.bounds = bounds(this, child) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val pref = super.getPreferredSize() + val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0) + val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0) + return JBDimension(w, h) + } + } + + open class Blocker : JPanel() { + init { + layout = BorderLayout() + isFocusable = true + } + + override fun updateUI() { + super.updateUI() + background = UiStyle.Colors.bg() + isOpaque = true + } + + override fun contains(x: Int, y: Int): Boolean { + if (!isVisible) return false + return super.contains(x, y) + } + + override fun doLayout() { + super.doLayout() + components.forEach { layoutTree(it) } + } + } +} + +private fun layoutTree(comp: java.awt.Component) { + comp.doLayout() + if (comp is Container) comp.components.forEach { layoutTree(it) } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index de0755a1ae2..0e8fd0b6f2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -191,6 +191,8 @@ action.Kilo.ToolWindowToolbar.text=Kilo Toolbar settings.kilo.displayName=Kilo Code settings.kilo.description=Configure Kilo Code AI coding assistant features and account settings. settings.models.displayName=Models +settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing. +settings.login.action=Open User Profile settings.models.defaultModel.title=Default Model settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured. settings.models.smallModel.title=Small Model diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt new file mode 100644 index 00000000000..b707c2c2c01 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt @@ -0,0 +1,145 @@ +package ai.kilocode.client.settings.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.codeInsight.hint.HintUtil +import com.intellij.ui.SeparatorComponent +import com.intellij.ui.components.JBLabel +import ai.kilocode.client.ui.UiStyle +import java.awt.Container +import java.awt.Rectangle +import javax.swing.AbstractButton +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.text.JTextComponent + +class SettingsRowsTest : BasePlatformTestCase() { + + fun `test rows do not insert separators`() { + val rows = SettingsRows() + + rows.row(SettingsRow("One", value = JButton("A"))) + rows.row(SettingsRow("Two", value = JButton("B"))) + + assertEquals(2, rows.componentCount) + assertTrue(components(rows).none { it is SeparatorComponent }) + } + + fun `test keyed update preserves row and value`() { + val rows = SettingsRows() + val value = JButton("A") + val row = rows.row("one", SettingsRow("One", "Before", value)) + + val updated = rows.update("one", "Updated", "After", value) + + assertSame(row, updated) + assertSame(value, components(row).first { it === value }) + assertTrue(text(row).contains("Updated")) + assertTrue(text(row).contains("After")) + } + + fun `test removing keyed row removes only that row`() { + val rows = SettingsRows() + val one = rows.row("one", SettingsRow("One", value = JButton("A"))) + val two = rows.row("two", SettingsRow("Two", value = JButton("B"))) + + assertSame(one, rows.remove("one")) + + assertEquals(1, rows.componentCount) + assertSame(two, rows.getComponent(0)) + } + + fun `test retain keeps requested keyed rows`() { + val rows = SettingsRows() + val one = rows.row("one", SettingsRow("One", value = JButton("A"))) + rows.row("two", SettingsRow("Two", value = JButton("B"))) + + rows.retain(setOf("one")) + + assertEquals(1, rows.componentCount) + assertSame(one, rows.getComponent(0)) + } + + fun `test top banner renders login action`() { + val top = SettingsTop() + + top.showNotLoggedIn {} + + assertTrue(text(top).contains("Sign in to Kilo Code")) + assertTrue(top.isVisible) + } + + fun `test settings panel keeps banner in scroll content and progress in overlay`() { + val panel = SettingsPanel() + + panel.top.showNotLoggedIn {} + panel.showProgress("Loading models...") + + assertTrue(text(panel.content).contains("Sign in to Kilo Code")) + assertFalse(text(panel.overlay).contains("Sign in to Kilo Code")) + assertTrue(panel.overlay.components.any { it === panel.progress }) + assertTrue(text(panel.progress).contains("Loading models...")) + } + + fun `test settings progress overlay is centered near top`() { + val panel = SettingsPanel().apply { setSize(400, 300) } + + panel.showProgress("Loading") + panel.doLayout() + + val size = panel.progress.preferredSize + assertEquals( + Rectangle((400 - size.width) / 2, UiStyle.Gap.pad(), size.width, size.height), + panel.progress.bounds, + ) + assertTrue(panel.overlay.contains(panel.progress.x + 1, panel.progress.y + 1)) + assertFalse(panel.overlay.contains(1, 1)) + } + + fun `test settings progress overlay retains label across updates`() { + val panel = SettingsPanel() + + panel.showProgress("Loading") + val label = components(panel.progress).filterIsInstance().single { it.text == "Loading" } + + panel.showProgress("Saving") + + assertSame(label, components(panel.progress).filterIsInstance().single { it.text == "Saving" }) + + panel.clearProgress() + assertFalse(panel.progress.isVisible) + } + + fun `test settings progress overlay uses information colors`() { + val panel = SettingsPanel() + val hint = HintUtil.getInformationHint() + + panel.showProgress("Loading") + + val label = components(panel.progress).filterIsInstance().single { it.text == "Loading" } + assertEquals(hint.textBackground, panel.progress.background) + assertEquals(hint.textForeground, panel.progress.foreground) + assertEquals(hint.textForeground, label.foreground) + } + + private fun text(component: JComponent): String = components(component) + .mapNotNull { + when (it) { + is JLabel -> it.text + is AbstractButton -> it.text + is JTextComponent -> it.text + else -> null + } + } + .joinToString("\n") + + private fun components(component: JComponent): List { + val out = mutableListOf() + fun visit(c: java.awt.Component) { + out += c + if (c is Container) c.components.forEach { visit(it) } + } + visit(component) + return out + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt new file mode 100644 index 00000000000..bd497a2c495 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt @@ -0,0 +1,116 @@ +package ai.kilocode.client.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JLayeredPane + +@Suppress("UnstableApiUsage") +class LayeredOverlayPanelTest : BasePlatformTestCase() { + + fun `test root owns content overlay and blocker layers`() { + val root = LayeredOverlayPanel() + + assertEquals(3, root.componentCount) + assertSame(root.content, root.components.first { it === root.content }) + assertSame(root.overlay, root.components.first { it === root.overlay }) + assertSame(root.blocker, root.components.first { it === root.blocker }) + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker)) + } + + fun `test root layout fills all immediate children`() { + val root = LayeredOverlayPanel().apply { setSize(320, 180) } + + root.doLayout() + + assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.blocker.bounds) + } + + fun `test root preferred size is max of content and overlay`() { + val root = LayeredOverlayPanel().apply { + content.preferredSize = Dimension(300, 120) + overlay.preferredSize = Dimension(180, 220) + } + + assertEquals(Dimension(300, 220), root.preferredSize) + } + + fun `test addOverlay applies callback bounds and delegates child layout`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val child = Probe() + + root.addOverlay(child) { _, item -> + Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) + } + root.doLayout() + + assertEquals(Rectangle(12, 34, 80, 24), child.bounds) + assertTrue(child.laid) + } + + fun `test overlay contains only visible overlay children`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val child = Probe() + + root.addOverlay(child) { _, item -> Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + assertTrue(root.overlay.contains(20, 40)) + assertFalse(root.overlay.contains(4, 4)) + + child.isVisible = false + assertFalse(root.overlay.contains(20, 40)) + } + + fun `test modal content is centered inside blocker`() { + val root = LayeredOverlayPanel().apply { setSize(200, 100) } + val child = Probe() + + root.setModalContent(child) + root.doLayout() + + assertTrue(root.blocker.isVisible) + assertEquals(1, root.blocker.componentCount) + assertEquals(Rectangle(60, 38, 80, 24), child.bounds) + } + + fun `test clearing modal content hides and removes blocker children`() { + val root = LayeredOverlayPanel().apply { setSize(200, 100) } + root.setModalContent(Probe()) + root.doLayout() + + root.setModalContent(null) + + assertFalse(root.blocker.isVisible) + assertEquals(0, root.blocker.componentCount) + } + + fun `test blocker contains reflects visibility`() { + val root = LayeredOverlayPanel().apply { setSize(200, 100) } + root.doLayout() + + root.setBlocked(false) + assertFalse(root.blocker.contains(50, 50)) + + root.setBlocked(true) + assertTrue(root.blocker.contains(50, 50)) + } + + private class Probe : BorderLayoutPanel() { + var laid = false + + init { + preferredSize = Dimension(80, 24) + } + + override fun doLayout() { + laid = true + super.doLayout() + } + } +} From e17c4f936c1c111d7019122b7c794661a610eb52 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 13:31:03 -0400 Subject: [PATCH 060/201] fix(jetbrains): stop duplicated streaming text in chat code blocks Replace the fragile covered/uncovered prefix dedup in SessionController with a stateless content-overlap glue: a text/reasoning delta only appends the portion that extends the current model tail. This eliminates the token-doubling (e.g. "importimport", "ReadRead a file") that occurred when multiple PartUpdated snapshots landed before the lagging PartDelta stream caught up. Also adds a MdViewHybrid fast path that grows the trailing code view in place while inside an open fenced code block, skipping the full commonmark re-parse and block diff per token, with html() recomputed lazily when stale. --- .changeset/retained-jetbrains-markdown.md | 5 + .../session/controller/SessionController.kt | 17 +- .../client/session/model/SessionModel.kt | 3 +- .../client/session/model/SessionModelEvent.kt | 7 +- .../session/ui/SessionMessageListPanel.kt | 9 +- .../client/session/views/MessageView.kt | 5 +- .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 408 +++++++++++++++--- .../controller/SessionUpdateQueueTest.kt | 142 +++++- .../session/ui/SessionMessageListPanelTest.kt | 26 ++ .../kilocode/client/ui/md/MdViewHybridTest.kt | 143 ++++++ 10 files changed, 701 insertions(+), 64 deletions(-) create mode 100644 .changeset/retained-jetbrains-markdown.md diff --git a/.changeset/retained-jetbrains-markdown.md b/.changeset/retained-jetbrains-markdown.md new file mode 100644 index 00000000000..e5e6cb0b0c7 --- /dev/null +++ b/.changeset/retained-jetbrains-markdown.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Improve JetBrains chat streaming performance by retaining existing markdown and code block views while responses stream, and render code blocks without showing raw fence markers during streamed updates. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index bf1a3513555..29adf9e05f6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -17,7 +17,9 @@ import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.ToolCallRef +import ai.kilocode.client.session.model.Text import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.SessionRef import ai.kilocode.client.telemetry.Telemetry @@ -897,7 +899,8 @@ class SessionController( is ChatEventDto.PartDelta -> { if (event.field == "text") { - model.appendDelta(event.messageID, event.partID, event.delta) + val delta = glue(event.messageID, event.partID, event.delta) + if (delta.isNotEmpty()) model.appendDelta(event.messageID, event.partID, delta) } } @@ -977,6 +980,18 @@ class SessionController( } } + private fun glue(messageId: String, partId: String, delta: String): String { + if (delta.isEmpty()) return delta + val cur = when (val content = model.content(messageId, partId)) { + is Text -> content.content + is Reasoning -> content.content + else -> return delta + } + val span = (minOf(cur.length, delta.length) downTo 1) + .firstOrNull { n -> cur.regionMatches(cur.length - n, delta, 0, n) } ?: 0 + return delta.substring(span) + } + private fun handleHidden(event: ChatEventDto): Boolean = when (event) { is ChatEventDto.Error, is ChatEventDto.PermissionAsked, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 5b356d0ff5e..c8f4c665f03 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -157,6 +157,7 @@ class SessionModel { fun appendDelta(messageId: String, contentId: String, delta: String) { val msg = entries[messageId] ?: return val existing = msg.parts[contentId] + val created = existing == null if (existing != null) { val buf = when (existing) { is Text -> existing.content @@ -170,7 +171,7 @@ class SessionModel { msg.parts[contentId] = content fire(SessionModelEvent.ContentAdded(messageId, content)) } - fire(SessionModelEvent.ContentDelta(messageId, contentId, delta)) + fire(SessionModelEvent.ContentDelta(messageId, contentId, delta, created)) updateHeader() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index 83b79b45b5d..1ca9fe5a342 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -36,7 +36,12 @@ sealed class SessionModelEvent { data class ContentRemoved(val messageId: String, val contentId: String) : SessionModelEvent() { override fun toString() = "ContentRemoved $messageId/$contentId" } - data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() { + data class ContentDelta( + val messageId: String, + val contentId: String, + val delta: String, + val created: Boolean = false, + ) : SessionModelEvent() { override fun toString() = "ContentDelta $messageId/$contentId" } data class StateChanged(val state: SessionState) : SessionModelEvent() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index d2255359cac..7d84b95b204 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -96,14 +96,11 @@ class SessionMessageListPanel( } is SessionModelEvent.ContentDelta -> { - // Use the full current content from the model rather than - // an incremental append. This avoids the double-write that - // occurs when ContentAdded and ContentDelta both fire for - // the same first delta (the model auto-creates the content - // on first appendDelta and fires both events in sequence). + if (event.created) return@addListener + val handled = msgToView[event.messageId]?.appendDelta(event.contentId, event.delta) == true + if (handled) return@addListener val content = model.content(event.messageId, event.contentId) if (content != null) msgToView[event.messageId]?.upsertPart(content) - refresh() } is SessionModelEvent.HistoryLoaded -> rebuild() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 68d48f9d9b3..cbec04f5efd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -182,10 +182,11 @@ class MessageView( } /** Append a streaming delta to the renderer for [contentId]. */ - fun appendDelta(contentId: String, delta: String) { - val part = parts[contentId] ?: return + fun appendDelta(contentId: String, delta: String): Boolean { + val part = parts[contentId] ?: return false part.appendDelta(delta) refresh() + return true } /** Look up a renderer by part id. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index ef5c402103c..e9740eaa409 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -126,8 +126,10 @@ internal class MdViewHybrid( private val source = StringBuilder() private var style = style private var rendered = "" - private var block: Disposable? = null private var disposed = false + private val blocks = mutableListOf() + private var openFence: Fence? = null + private var stale = false private val extensions = listOf( AutolinkExtension.create(), @@ -284,6 +286,7 @@ internal class MdViewHybrid( if (disposed) return if (this.selection === selection) return this.selection = selection + clearBlocks() syncBlocks() } @@ -315,6 +318,16 @@ internal class MdViewHybrid( override fun append(delta: String) { if (disposed) return if (delta.isEmpty()) return + val fence = openFence + val view = blocks.lastOrNull() as? CodeView + if (fence != null && view != null && clean(fence.char, delta)) { + source.append(delta) + view.grow(delta) + stale = true + root.revalidate() + root.repaint() + return + } source.append(delta) syncBlocks() } @@ -324,6 +337,8 @@ internal class MdViewHybrid( if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return source.clear() rendered = "" + openFence = null + stale = false clearBlocks() root.revalidate() root.repaint() @@ -340,7 +355,14 @@ internal class MdViewHybrid( override fun markdown(): String = source.toString() - override fun html(): String = rendered + override fun html(): String { + if (!stale) return rendered + val out = project(source.toString()) + rendered = out.html + openFence = out.open + stale = false + return rendered + } override fun overrideSheet(): String = MdCommon.rules(opts()) @@ -354,6 +376,8 @@ internal class MdViewHybrid( listeners.clear() source.clear() rendered = "" + openFence = null + stale = false clearBlocks() } @@ -362,37 +386,66 @@ internal class MdViewHybrid( val opts = opts() root.isOpaque = opts.opaque if (opts.opaque) root.background = opts.background - syncBlocks() + for (view in blocks) view.style(opts) + root.revalidate() + root.repaint() } private fun syncBlocks() { if (disposed) return val text = source.toString() - val doc = parser.parse(text) - val body = renderer.render(doc) - rendered = body - resetBlocks() + val out = project(text) + rendered = out.html + openFence = out.open + stale = false + val next = out.blocks if (text.isEmpty()) { + openFence = null + clearBlocks() root.revalidate() root.repaint() return } - val visitor = Visitor() - doc.accept(visitor) + sync(next) root.revalidate() root.repaint() } - private fun resetBlocks() { - block?.let { Disposer.dispose(it) } - block = Disposer.newDisposable("Markdown blocks") + private fun clearBlocks() { + blocks.forEach { Disposer.dispose(it.disposable) } + blocks.clear() root.removeAll() } - private fun clearBlocks() { - block?.let { Disposer.dispose(it) } - block = null - root.removeAll() + private fun sync(next: List) { + var at = 0 + while (at < blocks.size && at < next.size) { + val view = blocks[at] + val desc = next[at] + if (!view.compatible(desc)) break + view.update(desc) + at++ + } + removeBlocks(at) + for (desc in next.drop(at)) addBlock(view(desc)) + } + + private fun clean(char: Char, delta: String): Boolean { + if (delta.contains(char)) return false + val start = source.lastIndexOf("\n") + 1 + for (idx in start until source.length) { + if (source[idx] == char) return false + } + return true + } + + private fun removeBlocks(start: Int) { + if (start >= blocks.size) return + val idx = if (start == 0) 0 else start * 2 - 1 + while (root.componentCount > idx) root.remove(root.componentCount - 1) + val stale = blocks.drop(start) + repeat(blocks.size - start) { blocks.removeAt(blocks.lastIndex) } + stale.forEach { Disposer.dispose(it.disposable) } } private fun addGap() { @@ -400,15 +453,23 @@ internal class MdViewHybrid( root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) } - private fun addBlock(component: JComponent) { + private fun addBlock(view: View) { addGap() - component.alignmentX = JComponent.LEFT_ALIGNMENT - root.add(component) + view.component.alignmentX = JComponent.LEFT_ALIGNMENT + blocks.add(view) + root.add(view.component) } - private fun htmlBlock(node: Node): JComponent { + private fun view(desc: Desc): View { + val disposable = Disposer.newDisposable("Markdown block") + return when (desc) { + is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable) + is Desc.Code -> CodeView(desc, codeBlock(desc.text, desc.file, disposable), disposable) + } + } + + private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane { val opts = opts() - val body = renderer.render(node) return JBHtmlPane( JBHtmlPaneStyleConfiguration { enableInlineCodeBackground = true @@ -422,7 +483,7 @@ internal class MdViewHybrid( isOpaque = opts.opaque background = opts.background text = "$body" - block?.let { selection?.register(this, it) } + selection?.register(this, disposable) addHyperlinkListener { e -> if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener val href = e.description ?: return@addHyperlinkListener @@ -432,24 +493,20 @@ internal class MdViewHybrid( } } - private fun codeBlock(text: String, lang: String?): JComponent { + private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { val opts = opts() val value = text.trimEnd('\n') val field = runCatching { - CodeField(file(lang), opts, text).also { ed -> - block?.let { ed.setDisposedWith(it) } - block?.let { selection?.register(ed, it) } + CodeField(file, opts, text).also { ed -> + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) } }.getOrElse { err -> LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) - textArea(text, opts) + textArea(text, opts, disposable) } - val height = codeHeight(field, value) - val width = codeWidth(field, value) - field.preferredSize = Dimension(width, height) - field.minimumSize = Dimension(0, height) - field.maximumSize = Dimension(Int.MAX_VALUE, height) - return object : JBScrollPane(field) { + sizeCodeField(field, value) + val pane = object : JBScrollPane(field) { override fun doLayout() { super.doLayout() val view = viewport.view ?: return @@ -457,7 +514,14 @@ internal class MdViewHybrid( if (size.height <= 0 || view.height == size.height) return view.setSize(view.width.coerceAtLeast(size.width), size.height) } - }.apply { + } + styleCodePane(pane, opts) + sizeCodePane(pane, field) + return pane + } + + private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) { + pane.apply { border = JBUI.Borders.customLine(opts.tableBorder, SessionUiStyle.View.Code.BORDER_WIDTH) viewportBorder = JBUI.Borders.empty( SessionUiStyle.View.Code.topPadding(), @@ -475,14 +539,26 @@ internal class MdViewHybrid( horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) horizontalScrollBar.isOpaque = true verticalScrollBar.preferredSize = JBUI.emptySize() - val pad = viewportBorder.getBorderInsets(this) - val size = height + insets.top + insets.bottom + pad.top + pad.bottom + horizontalScrollBar.preferredSize.height - preferredSize = Dimension(0, size) - minimumSize = Dimension(0, size) - maximumSize = Dimension(Int.MAX_VALUE, size) } } + private fun sizeCodeField(component: JComponent, text: String) { + val height = codeHeight(component, text) + val width = codeWidth(component, text) + component.preferredSize = Dimension(width, height) + component.minimumSize = Dimension(0, height) + component.maximumSize = Dimension(Int.MAX_VALUE, height) + } + + private fun sizeCodePane(pane: JBScrollPane, component: JComponent) { + val pad = pane.viewportBorder.getBorderInsets(pane) + val height = component.preferredSize.height + pane.insets.top + pane.insets.bottom + + pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height + pane.preferredSize = Dimension(0, height) + pane.minimumSize = Dimension(0, height) + pane.maximumSize = Dimension(Int.MAX_VALUE, height) + } + private fun codeWidth(component: JComponent, text: String): Int { val metrics = component.getFontMetrics(component.font) val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0 @@ -503,18 +579,22 @@ internal class MdViewHybrid( return line * rows } - private fun textArea(text: String, opts: MdStyle) = JBTextArea(text.trimEnd('\n')).apply { + private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = JBTextArea(text.trimEnd('\n')).apply { isEditable = false lineWrap = false - isOpaque = opts.opaque - background = opts.preBg - foreground = opts.preFg - font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + styleTextArea(this, opts) border = JBUI.Borders.empty( SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, ) - block?.let { selection?.register(this, it) } + selection?.register(this, disposable) + } + + private fun styleTextArea(area: JBTextArea, opts: MdStyle) { + area.isOpaque = opts.opaque + area.background = opts.preBg + area.foreground = opts.preFg + area.font = Font(opts.codeFont, Font.PLAIN, style.editorSize) } private inner class CodeField(file: FileType, opts: MdStyle, value: String) : @@ -586,17 +666,247 @@ internal class MdViewHybrid( ) } + private fun collect(doc: Node): List { + val visitor = Visitor() + doc.accept(visitor) + return visitor.blocks + } + + private fun project(text: String): Projection { + val blocks = mutableListOf() + val html = StringBuilder() + val md = StringBuilder() + val lines = lines(text) + var trailing: Fence? = null + var idx = 0 + + fun flush() { + if (md.isEmpty()) return + val doc = parser.parse(md.toString()) + html.append(renderer.render(doc)) + blocks.addAll(collect(doc)) + md.clear() + } + + while (idx < lines.size) { + val line = lines[idx] + val open = opener(line.text) + if (open == null) { + val pending = idx == lines.lastIndex && pendingOpener(line.text) + if (pending) { + flush() + blocks.add(Desc.Code("", PlainTextFileType.INSTANCE)) + html.append(codeHtml("")) + } else { + md.append(line.text).append(line.end) + } + idx++ + continue + } + + flush() + idx++ + val code = StringBuilder() + var closed = false + var trimmed = false + while (idx < lines.size) { + val item = lines[idx] + val close = closer(item.text, open) + if (close) { + closed = true + idx++ + break + } + val partial = idx == lines.lastIndex && partialCloser(item.text, open) + if (partial) trimmed = true + if (!partial) code.append(item.text).append(item.end) + idx++ + } + val desc = Desc.Code(code.toString(), file(open.info)) + blocks.add(desc) + html.append(codeHtml(desc.text)) + trailing = if (!closed && !trimmed) open else null + } + + flush() + return Projection(html.toString(), blocks, trailing) + } + + private fun lines(text: String): List { + if (text.isEmpty()) return emptyList() + val lines = mutableListOf() + var start = 0 + while (start < text.length) { + val end = text.indexOf('\n', start) + if (end == -1) { + lines.add(Line(text.substring(start), "")) + break + } + lines.add(Line(text.substring(start, end), "\n")) + start = end + 1 + } + return lines + } + + private fun opener(text: String): Fence? { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return null + val char = trimmed.firstOrNull() ?: return null + if (char != '`' && char != '~') return null + val size = trimmed.takeWhile { it == char }.length + if (size < 3) return null + val info = trimmed.drop(size).trim() + if (char == '`' && info.contains('`')) return null + return Fence(char, size, info) + } + + private fun closer(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size < fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun pendingOpener(text: String): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val char = trimmed.firstOrNull() ?: return false + if (char != '`' && char != '~') return false + val size = trimmed.takeWhile { it == char }.length + if (size !in 1..2) return false + return trimmed.drop(size).isBlank() + } + + private fun partialCloser(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size !in 1 until fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun codeHtml(text: String): String = "
${escape(text)}
\n" + + private fun escape(text: String): String = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + + private sealed class Desc { + data class Html(val body: String) : Desc() + data class Code(val text: String, val file: FileType) : Desc() + } + + private data class Projection(val html: String, val blocks: List, val open: Fence?) + + private data class Line(val text: String, val end: String) + + private data class Fence(val char: Char, val size: Int, val info: String) + + private abstract inner class View( + var desc: Desc, + val component: JComponent, + val disposable: Disposable, + ) { + abstract fun compatible(desc: Desc): Boolean + abstract fun update(desc: Desc) + abstract fun style(opts: MdStyle) + } + + private inner class HtmlView(desc: Desc.Html, private val pane: JBHtmlPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Html + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + pane.text = "${(desc as Desc.Html).body}" + } + + override fun style(opts: MdStyle) { + pane.isOpaque = opts.opaque + pane.background = opts.background + val item = desc as Desc.Html + pane.text = "${item.body}" + } + } + + private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).file == desc.file + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + val value = (desc as Desc.Code).text.trimEnd('\n') + val view = pane.viewport.view + when (view) { + is CodeField -> view.text = value + is JBTextArea -> view.text = value + } + if (view is JComponent) { + sizeCodeField(view, value) + sizeCodePane(pane, view) + } + } + + fun grow(delta: String) { + val item = desc as Desc.Code + val next = item.copy(text = item.text + delta) + desc = next + val value = next.text.trimEnd('\n') + val view = pane.viewport.view + when (view) { + is CodeField -> view.text = value + is JBTextArea -> view.text = value + } + if (view is JComponent) { + sizeCodeField(view, value) + sizeCodePane(pane, view) + } + } + + override fun style(opts: MdStyle) { + styleCodePane(pane, opts) + val view = pane.viewport.view + when (view) { + is CodeField -> { + view.font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + view.background = opts.preBg + } + is JBTextArea -> styleTextArea(view, opts) + } + if (view is JComponent) { + val text = when (view) { + is CodeField -> view.text + is JBTextArea -> view.text + else -> "" + } + sizeCodeField(view, text) + sizeCodePane(pane, view) + } + } + } + private inner class Visitor : AbstractVisitor() { + val blocks = mutableListOf() + override fun visit(document: Document) { visitChildren(document) } override fun visit(code: FencedCodeBlock) { - addBlock(codeBlock(code.literal, code.info)) + blocks.add(Desc.Code(code.literal, file(code.info))) } override fun visit(code: IndentedCodeBlock) { - addBlock(codeBlock(code.literal, null)) + blocks.add(Desc.Code(code.literal, file(null))) } public override fun visitChildren(parent: Node) { @@ -604,7 +914,9 @@ internal class MdViewHybrid( while (child != null) { val next = child.next if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) - if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) addBlock(htmlBlock(child)) + if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) { + blocks.add(Desc.Html(renderer.render(child))) + } child = next } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt index 75c2e6887ab..11d4e92548b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt @@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.ToolRefDto +import kotlinx.coroutines.ExperimentalCoroutinesApi class SessionUpdateQueueTest : SessionControllerTestBase() { @@ -234,6 +235,137 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertEquals(listOf("hello world"), delta.map { it.delta }) } + fun `test text snapshot covered delta is not duplicated`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import")), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import"), flush = false) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + import + """, + m, + ) + } + + fun `test text snapshot covered prefix is trimmed from merged delta`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import java")), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", " java.util.List;"), flush = false) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + import java.util.List; + """, + m, + ) + } + + fun `test repeated snapshots then lagging merged delta is not duplicated`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import java"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import java"), flush = false) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + import java + """, + m, + ) + } + + fun `test multi round snapshot delta interleave stays single copy`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import")) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", " java")) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java.util.List;\n"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", ".util.List;\n")) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java.util.List;\n\npublic class StreamBasics {\n}\n```"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "\npublic class StreamBasics {\n}\n```")) + + assertModel( + """ + assistant#msg1 + text#prt1: + ```java + import java.util.List; + + public class StreamBasics { + } + ``` + """, + m, + ) + } + + fun `test per token snapshot plus delta interleave does not double text or code`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + // Reproduces the streamed screenshot: for every token the backend sends a full + // PartUpdated snapshot AND a matching incremental PartDelta. The old dedup doubled + // each token ("ReadRead", "inputinput.txt.txt"); glue must keep a single copy. + val tokens = listOf( + "**Python**", "\n\n", "Read", " a", " file", " line", " by", " line", ",", + " which", " is", " stream-like", " because", " it", " avoids", " loading", + " the", " whole", " file", " into", " memory", ":", "\n\n", + "```python\n", "with", " open", "(\"input.txt\",", " \"r\")", " as", " file:", "\n", + " for", " line", " in", " file:", "\n", " print", "(line.strip())", "\n", + "```", + ) + val sb = StringBuilder() + for (token in tokens) { + sb.append(token) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = sb.toString()))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", token)) + } + settle() + flush() + + val text = (m.model.message("msg1")!!.parts["prt1"] as ai.kilocode.client.session.model.Text).content.toString() + assertEquals(sb.toString(), text) + } + fun `test visible controller flushes after cadence`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() @@ -346,15 +478,15 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertEquals(SessionState.Idle, m.model.state) } + @OptIn(ExperimentalCoroutinesApi::class) fun `test condensed and raw controller end with same final state on large corpus`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() val events = corpus() - val condensed = runCorpus(events, true) - val raw = runCorpus(events, false) - val a = snapshot(condensed) - val b = snapshot(raw) + val a = snapshot(runCorpus(events, true)) + rpc.events.resetReplayCache() + val b = snapshot(runCorpus(events, false)) if (a != b) fail("condensed=\n$a\nraw=\n$b") assertEquals(SessionState.Idle, a.state) @@ -362,7 +494,7 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertTrue(a.body.contains("assistant#msg2")) assertTrue(a.body.contains("diff: src/A.kt src/B.kt")) assertTrue(a.body.contains("todo: [completed] ship feature")) - assertEquals(4, a.compacted) + assertEquals(2, a.compacted) } fun `test update hooks run on EDT around queued model batch`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 6d5a067309d..588d4e95167 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -26,6 +26,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Container +import javax.swing.JPanel /** * Tests for [SessionMessageListPanel] — structural and index integrity. @@ -186,6 +187,31 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("hello world", tv.markdown()) } + fun `test ContentDelta preserves TextView and markdown component`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first\n\nsecond")) + val mv = panel.findMessage("a1")!! + val tv = mv.part("p1") as TextView + val comp = tv.md.component + val first = (comp as JPanel).components.first() + + model.appendDelta("a1", "p1", " more") + + assertSame(tv, mv.part("p1")) + assertSame(comp, tv.md.component) + assertSame(first, comp.components.first()) + assertEquals("first\n\nsecond more", tv.markdown()) + } + + fun `test created ContentDelta is not double applied`() { + model.upsertMessage(msg("a1", "assistant")) + + model.appendDelta("a1", "p1", "hello") + + val tv = panel.findMessage("a1")!!.part("p1") as TextView + assertEquals("hello", tv.markdown()) + } + fun `test ContentRemoved removes PartView from MessageView`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", part("p1", "a1", "text", text = "x")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 47f4e4368c6..0ff05c04f9a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import java.awt.BorderLayout @@ -128,6 +129,146 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue("streamed editor should not be clipped vertically", editor.height >= editor.preferredSize.height) } + fun `test appending later html block preserves earlier block component`() { + view.set("first\n\nsecond") + val first = htmls().first() + + view.append(" more") + + assertSame(first, htmls().first()) + assertTrue(view.html().contains("second more")) + } + + fun `test streaming fenced code block preserves editor component`() { + view.append("```java\nclass Example {\n") + val pane = scrolls().single() + val editor = editors().single() + val initial = pane.preferredSize.height + + view.append("void method() {}\n}\n```") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("class Example {\nvoid method() {}\n}", editor.text) + assertTrue(scrolls().single().preferredSize.height >= initial) + } + + fun `test streaming code body append reuses editor and stays correct`() { + view.append("```java\nclass A {\n") + val pane = scrolls().single() + val editor = editors().single() + + view.append(" void run() {\n") + view.append(" }\n") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("class A {\n void run() {\n }", editor.text) + assertEquals("```java\nclass A {\n void run() {\n }\n", view.markdown()) + assertTrue(view.html().contains("class A")) + + view.append("}\n```") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("class A {\n void run() {\n }\n}", editor.text) + assertEquals("```java\nclass A {\n void run() {\n }\n}\n```", view.markdown()) + } + + fun `test streaming code body append keeps html in sync`() { + view.append("```java\n") + view.append("if (a < b) {\n") + + assertTrue(view.html().contains("a < b")) + assertEquals("if (a < b) {", editors().single().text) + } + + fun `test streaming partial fence opener renders code block without raw marker`() { + view.append("`") + + val pane = scrolls().single() + val editor = editors().single() + + assertEquals("", editor.text) + assertFalse(view.html().contains("`")) + + view.append("`") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("", editor.text) + assertFalse(view.html().contains("``")) + } + + fun `test streaming language prefix stays out of code text`() { + view.append("```") + view.append("p") + view.append("ython\nprint(1)\n") + + assertEquals("print(1)", editors().single().text) + assertFalse(view.html().contains("```")) + assertFalse(view.html().contains("python")) + } + + fun `test streaming partial closing fence stays out of code text`() { + view.append("```python\nprint(1)\n") + val pane = scrolls().single() + val editor = editors().single() + + view.append("`") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("print(1)", editor.text) + + view.append("`") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("print(1)", editor.text) + } + + fun `test completed closing fence preserves code block and renders following markdown`() { + view.append("```python\nprint(1)\n``") + val pane = scrolls().single() + val editor = editors().single() + + view.append("`\n\nafter") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals("print(1)", editor.text) + assertEquals(1, scrolls().size) + assertEquals(1, htmls().size) + assertTrue(view.html().contains("after")) + } + + fun `test appending new block preserves existing code block component`() { + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val editor = editors().single() + + view.append("\n\nhello") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals(1, scrolls().size) + assertEquals(1, htmls().size) + } + + fun `test incompatible suffix replacement preserves prefix block`() { + view.set("before\n\n```kotlin\nval value = 1\n```") + val prefix = htmls().single() + val editor = editors().single().getEditor(true)!! + + view.set("before\n\nafter") + drainEdt() + + assertSame(prefix, htmls().first()) + assertTrue(editor.isDisposed) + assertTrue(scrolls().isEmpty()) + } + fun `test java code block with blank lines fits vertically`() { val code = """ import java.util.List; @@ -277,6 +418,8 @@ class MdViewHybridTest : BasePlatformTestCase() { private fun scrolls(): List = (view.component as JPanel).components.filterIsInstance() + private fun htmls(): List = (view.component as JPanel).components.filterIsInstance() + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } private fun type(ext: String): FileType { From 03029eea47874bf9699053c1ec51478963fe9ce9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 13:56:23 -0400 Subject: [PATCH 061/201] fix(kilo-jetbrains): surface model settings save failures --- .../ai/kilocode/client/KiloNotifications.kt | 19 ++ .../ai/kilocode/client/app/KiloAppService.kt | 8 + .../settings/models/ModelsSettingsUi.kt | 27 ++- .../client/settings/ui/SettingsPanel.kt | 6 + .../settings/ui/SettingsProgressOverlay.kt | 37 +++- .../kotlin/ai/kilocode/client/ui/UiStyle.kt | 12 ++ .../resources/kilo.jetbrains.frontend.xml | 5 + .../resources/messages/KiloBundle.properties | 2 + .../settings/models/ModelsSettingsUiTest.kt | 202 ++++++++++++++++++ .../client/settings/ui/SettingsRowsTest.kt | 63 +++++- .../kilocode/client/testing/FakeAppRpcApi.kt | 5 + 11 files changed, 369 insertions(+), 17 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt new file mode 100644 index 00000000000..c0e2e727704 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt @@ -0,0 +1,19 @@ +package ai.kilocode.client + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.ProjectManager + +object KiloNotifications { + private const val GROUP = "Kilo Code" + + fun error(title: String, content: String? = null) { + val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault } + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP) + ?.createNotification(title, content ?: "", NotificationType.ERROR) + ?: Notification(GROUP, title, content ?: "", NotificationType.ERROR) + notification.notify(project) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 5d2e9c45db1..38f7a1d8dff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -224,6 +224,14 @@ class KiloAppService internal constructor( null } + fun updateConfigAsync( + patch: ConfigPatchDto, + done: (KiloAppStateDto?) -> Unit, + ): Job = cs.launch { + val state = updateConfig(patch) + done(state) + } + private fun setModelState(state: ModelStateDto) { _models.value = state _favorites.value = state.favorite diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index 19544d82447..e7c7441ab34 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.settings.models +import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle @@ -24,6 +25,7 @@ import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.ProvidersDto import com.intellij.ide.DataManager import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId @@ -67,6 +69,8 @@ internal class ModelsSettingsUi( private var allItems: List = emptyList() private var saving = false private var pending: ModelsDraft? = null + private var saveError: String? = null + private var disposed = false init { setContent(form) @@ -84,6 +88,7 @@ internal class ModelsSettingsUi( fun resetDraft() { checkEdt() draft = baseline + saveError = null if (!saving) clearProgress() sync() } @@ -97,11 +102,15 @@ internal class ModelsSettingsUi( if (patch.values.isEmpty() && patch.agents.isEmpty()) return pending = next saving = true + saveError = null showProgress(KiloBundle.message("settings.models.save.pending")) sync() - cs.launch { - val state = app.updateConfig(patch) - withContext(edt) { + app.updateConfigAsync(patch) { state -> + ApplicationManager.getApplication().invokeLater({ + if (disposed) { + if (state == null) KiloNotifications.error(KiloBundle.message("settings.models.save.failed")) + return@invokeLater + } if (state != null) { appState = state val base = modelsDraft(state.config, agents) @@ -109,17 +118,18 @@ internal class ModelsSettingsUi( draft = next pending = null saving = false + saveError = null clearProgress() sync() - return@withContext + return@invokeLater } baseline = prev draft = next pending = null saving = false - clearProgress() + saveError = KiloBundle.message("settings.models.save.failed") sync() - } + }, ModalityState.any()) } } @@ -164,6 +174,7 @@ internal class ModelsSettingsUi( @RequiresEdt fun dispose() { checkEdt() + disposed = true jobs.forEach { it.cancel() } jobs.clear() cs.cancel() @@ -236,8 +247,11 @@ internal class ModelsSettingsUi( else -> top.hideBanner() } } + val err = saveError if (saving || state == ModelsStatus.SAVING) { showProgress(KiloBundle.message("settings.models.save.pending")) + } else if (err != null) { + showError(err) } else if (state == ModelsStatus.UNAVAILABLE || state == ModelsStatus.LOADING) { showProgress(KiloBundle.message("settings.models.loading")) } else { @@ -346,6 +360,7 @@ internal class ModelsSettingsUi( private fun update(fn: ModelsDraft.() -> ModelsDraft) { checkEdt() draft = draft.fn() + saveError = null sync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt index 2e5293a482d..be08e0ebe72 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt @@ -43,6 +43,12 @@ internal open class SettingsPanel : LayeredOverlayPanel() { overlay.repaint() } + fun showError(text: String) { + progress.showError(text) + overlay.revalidate() + overlay.repaint() + } + fun clearProgress() { progress.clearProgress() overlay.revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt index cff8c3b95eb..25b7664f3d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt @@ -1,7 +1,6 @@ package ai.kilocode.client.settings.ui import ai.kilocode.client.ui.UiStyle -import com.intellij.codeInsight.hint.HintUtil import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -11,7 +10,10 @@ import java.awt.RenderingHints import javax.swing.JPanel internal class SettingsProgressOverlay : JPanel(BorderLayout()) { + private enum class Kind { INFO, ERROR } + private var label: JBLabel? = null + private var kind: Kind? = null init { val view = JBLabel() @@ -24,7 +26,19 @@ internal class SettingsProgressOverlay : JPanel(BorderLayout()) { } fun showProgress(text: String) { + show(text, Kind.INFO) + } + + fun showError(text: String) { + show(text, Kind.ERROR) + } + + private fun show(text: String, next: Kind) { val view = requireNotNull(label) + if (kind != next) { + kind = next + syncColors() + } if (view.text != text) view.text = text if (!isVisible) isVisible = true revalidate() @@ -46,19 +60,32 @@ internal class SettingsProgressOverlay : JPanel(BorderLayout()) { } private fun syncColors() { - val hint = HintUtil.getInformationHint() - background = hint.textBackground - foreground = hint.textForeground + val current = kind ?: Kind.INFO + background = when (current) { + Kind.INFO -> UiStyle.Colors.infoOverlayBackground() + Kind.ERROR -> UiStyle.Colors.errorOverlayBackground() + } + foreground = when (current) { + Kind.INFO -> UiStyle.Colors.infoOverlayForeground() + Kind.ERROR -> UiStyle.Colors.errorOverlayForeground() + } label?.foreground = foreground } + private fun borderColor() = when (kind ?: Kind.INFO) { + Kind.INFO -> UiStyle.Colors.infoOverlayBorder() + Kind.ERROR -> UiStyle.Colors.errorOverlayBorder() + } + override fun paintComponent(g: Graphics) { val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = background val arc = UiStyle.Arc.component() + g2.color = background g2.fillRoundRect(0, 0, width, height, arc, arc) + g2.color = borderColor() + g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) } finally { g2.dispose() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 3f9b680cf40..575f028e567 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -111,6 +111,18 @@ object UiStyle { ?: UIUtil.getContextHelpForeground() } + fun infoOverlayBackground(): Color = JBUI.CurrentTheme.NotificationInfo.backgroundColor() + + fun infoOverlayForeground(): Color = JBUI.CurrentTheme.NotificationInfo.foregroundColor() + + fun infoOverlayBorder(): Color = JBUI.CurrentTheme.NotificationInfo.borderColor() + + fun errorOverlayBackground(): Color = JBUI.CurrentTheme.NotificationError.backgroundColor() + + fun errorOverlayForeground(): Color = JBUI.CurrentTheme.NotificationError.foregroundColor() + + fun errorOverlayBorder(): Color = JBUI.CurrentTheme.NotificationError.borderColor() + internal fun contrast(base: Color, delta: Int): Color { val step = if (bright(base)) -delta else delta return Color( diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 66daeba58a7..c54efa1e297 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -10,6 +10,11 @@ + + () + ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe( + Notifications.TOPIC, + object : Notifications { + override fun notify(notification: Notification) { + notes.add(notification) + } + }, + ) + project.messageBus.connect(testRootDisposable).subscribe( + Notifications.TOPIC, + object : Notifications { + override fun notify(notification: Notification) { + notes.add(notification) + } + }, + ) + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + rpc.configUpdateError = RuntimeException("save failed") + + edt { + select(panel, "new") + panel.applyDraft() + panel.dispose() + ui = null + } + rpc.configUpdateGate?.complete(Unit) + + flushUntil { + notes.any { it.groupId == "Kilo Code" && it.type == NotificationType.ERROR } + } + assertEquals(1, rpc.configUpdateAttempts) + assertTrue(notes.any { it.title == "Failed to save model settings" }) + } + + private fun providers(): ProvidersDto = ProvidersDto( + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf( + "old" to ModelDto(id = "old", name = "Old"), + "new" to ModelDto(id = "new", name = "New"), + ), + ), + ), + connected = emptyList(), + defaults = emptyMap(), + ) + + private fun select(panel: ModelsSettingsUi, id: String) { + val picker = components(panel).filterIsInstance().first() + picker.onSelect(ModelPicker.Item(id, id.replaceFirstChar { it.titlecase() }, "kilo", "Kilo")) + } + + private fun requireUi(): ModelsSettingsUi = requireNotNull(ui) + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(20) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { out.add(it) } + is JLabel -> comp.text?.let { out.add(it) } + is JTextComponent -> comp.text?.let { out.add(it) } + is JTextField -> comp.text?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt index b707c2c2c01..a491df9fa92 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt @@ -1,12 +1,13 @@ package ai.kilocode.client.settings.ui +import ai.kilocode.client.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.codeInsight.hint.HintUtil import com.intellij.ui.SeparatorComponent import com.intellij.ui.components.JBLabel -import ai.kilocode.client.ui.UiStyle +import java.awt.Color import java.awt.Container import java.awt.Rectangle +import java.awt.image.BufferedImage import javax.swing.AbstractButton import javax.swing.JButton import javax.swing.JComponent @@ -112,14 +113,64 @@ class SettingsRowsTest : BasePlatformTestCase() { fun `test settings progress overlay uses information colors`() { val panel = SettingsPanel() - val hint = HintUtil.getInformationHint() panel.showProgress("Loading") val label = components(panel.progress).filterIsInstance().single { it.text == "Loading" } - assertEquals(hint.textBackground, panel.progress.background) - assertEquals(hint.textForeground, panel.progress.foreground) - assertEquals(hint.textForeground, label.foreground) + assertEquals(UiStyle.Colors.infoOverlayBackground(), panel.progress.background) + assertEquals(UiStyle.Colors.infoOverlayForeground(), panel.progress.foreground) + assertEquals(UiStyle.Colors.infoOverlayForeground(), label.foreground) + } + + fun `test settings progress overlay paints styled background`() { + val panel = SettingsPanel() + + panel.showProgress("Saving") + panel.progress.setSize(panel.progress.preferredSize) + panel.progress.doLayout() + + assertEquals(UiStyle.Colors.infoOverlayBackground().rgb, paint(panel.progress, UiStyle.Gap.lg(), panel.progress.height / 2).rgb) + assertNotSameColor(UiStyle.Colors.bg(), panel.progress.background) + } + + fun `test settings progress overlay can show error`() { + val panel = SettingsPanel() + + panel.showError("Failed to save model settings") + + val label = components(panel.progress).filterIsInstance().single { it.text == "Failed to save model settings" } + assertTrue(panel.progress.isVisible) + assertEquals(UiStyle.Colors.errorOverlayBackground(), panel.progress.background) + assertEquals(UiStyle.Colors.errorOverlayForeground(), panel.progress.foreground) + assertEquals(UiStyle.Colors.errorOverlayForeground(), label.foreground) + } + + fun `test settings progress overlay switches error back to info`() { + val panel = SettingsPanel() + + panel.showError("Failed") + val label = components(panel.progress).filterIsInstance().single { it.text == "Failed" } + panel.showProgress("Saving") + + assertSame(label, components(panel.progress).filterIsInstance().single { it.text == "Saving" }) + assertEquals(UiStyle.Colors.infoOverlayBackground(), panel.progress.background) + assertEquals(UiStyle.Colors.infoOverlayForeground(), panel.progress.foreground) + assertEquals(UiStyle.Colors.infoOverlayForeground(), label.foreground) + } + + private fun paint(component: JComponent, x: Int, y: Int): Color { + val image = BufferedImage(component.width, component.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + try { + component.paint(g) + } finally { + g.dispose() + } + return Color(image.getRGB(x, y), true) + } + + private fun assertNotSameColor(a: Color, b: Color) { + assertFalse("Expected colors to differ: $a", a.rgb == b.rgb) } private fun text(component: JComponent): String = components(component) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 783047d5f3d..35d1c8c2123 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -35,6 +35,9 @@ class FakeAppRpcApi : KiloAppRpcApi { val cleared = mutableListOf() val variants = mutableListOf() val configPatches = mutableListOf() + var configUpdateAttempts = 0 + private set + var configUpdateGate: CompletableDeferred? = null var configUpdateError: Exception? = null var connected = false @@ -120,6 +123,8 @@ class FakeAppRpcApi : KiloAppRpcApi { override suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto { assertNotEdt("updateConfig") + configUpdateAttempts += 1 + configUpdateGate?.await() configUpdateError?.let { throw it } configPatches.add(patch) val current = state.value From 1856776d8a3ab8fa13bced8ae4cd3ae31399ff77 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 2 Jun 2026 20:08:49 +0200 Subject: [PATCH 062/201] revert(jetbrains): remove sync-only compatibility shim --- .../kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index e124accafc4..a936de20ed1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -329,5 +329,4 @@ class KiloBackendSessionManager( } private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() - private fun Double.safeInt() = toLong().safeInt() } From 2183102843a896675b8855504f5964808851ea42 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 14:30:12 -0400 Subject: [PATCH 063/201] fix(kilo-jetbrains): stabilize model settings layout --- .changeset/quiet-garden-model-settings.md | 5 ++ .../settings/models/ModelsSettingsUi.kt | 35 +++++++----- .../client/settings/ui/SettingsPanel.kt | 27 ++++++++- .../client/settings/ui/SettingsRow.kt | 22 ++++++-- .../settings/models/ModelsSettingsUiTest.kt | 36 ++++++++++++ .../client/settings/ui/SettingsRowsTest.kt | 55 +++++++++++++++++++ 6 files changed, 158 insertions(+), 22 deletions(-) create mode 100644 .changeset/quiet-garden-model-settings.md diff --git a/.changeset/quiet-garden-model-settings.md b/.changeset/quiet-garden-model-settings.md new file mode 100644 index 00000000000..25be5c853d5 --- /dev/null +++ b/.changeset/quiet-garden-model-settings.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix model settings layout and prevent login banner flicker while saving. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index e7c7441ab34..6bc942b5a05 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -233,20 +233,7 @@ internal class ModelsSettingsUi( ready = appState.status == KiloAppStatusDto.READY, authenticated = appState.profile != null, ) - if (bannerVisible) { - top.showNotLoggedIn { openProfile(it) } - } else { - when (state) { - ModelsStatus.LOAD_FAILED -> top.showBanner( - KiloBundle.message("settings.models.load.failed"), - emptyList(), - SettingsBannerKind.ERROR, - ) - ModelsStatus.NO_PROVIDERS -> top.showBanner(KiloBundle.message("settings.models.noProviders"), emptyList()) - ModelsStatus.MODES_FAILED -> top.showBanner(KiloBundle.message("settings.models.modes.failed"), emptyList()) - else -> top.hideBanner() - } - } + syncBanner(state, bannerVisible) val err = saveError if (saving || state == ModelsStatus.SAVING) { showProgress(KiloBundle.message("settings.models.save.pending")) @@ -270,6 +257,26 @@ internal class ModelsSettingsUi( } } + @RequiresEdt + private fun syncBanner(state: ModelsStatus, login: Boolean) { + checkEdt() + if (login) { + top.showNotLoggedIn { openProfile(it) } + return + } + if ((saving || state == ModelsStatus.LOADING || state == ModelsStatus.SAVING) && top.isVisible) return + when (state) { + ModelsStatus.LOAD_FAILED -> top.showBanner( + KiloBundle.message("settings.models.load.failed"), + emptyList(), + SettingsBannerKind.ERROR, + ) + ModelsStatus.NO_PROVIDERS -> top.showBanner(KiloBundle.message("settings.models.noProviders"), emptyList()) + ModelsStatus.MODES_FAILED -> top.showBanner(KiloBundle.message("settings.models.modes.failed"), emptyList()) + else -> top.hideBanner() + } + } + private fun items(includeSmall: Boolean): List { val cfg = providers ?: return emptyList() return cfg.providers diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt index be08e0ebe72..40e75393832 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt @@ -3,10 +3,14 @@ package ai.kilocode.client.settings.ui import ai.kilocode.client.ui.LayeredOverlayPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis import com.intellij.ui.components.JBScrollPane import java.awt.BorderLayout +import java.awt.Dimension import java.awt.Rectangle import javax.swing.JComponent +import javax.swing.ScrollPaneConstants +import javax.swing.Scrollable internal open class SettingsPanel : LayeredOverlayPanel() { val top = SettingsTop() @@ -14,11 +18,14 @@ internal open class SettingsPanel : LayeredOverlayPanel() { val progress = SettingsProgressOverlay() init { - val body = Stack.vertical() + val body = SettingsBody() .next(top) .gap(UiStyle.Gap.lg()) .next(settings) - content.add(JBScrollPane(body).apply { border = null }, BorderLayout.CENTER) + content.add(JBScrollPane(body).apply { + border = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + }, BorderLayout.CENTER) addOverlay(progress) { pane, child -> val size = child.preferredSize Rectangle( @@ -55,3 +62,19 @@ internal open class SettingsPanel : LayeredOverlayPanel() { overlay.repaint() } } + +private class SettingsBody : Stack(StackAxis.VERTICAL), Scrollable { + override fun getScrollableTracksViewportWidth() = true + override fun getScrollableTracksViewportHeight() = false + override fun getPreferredScrollableViewportSize(): Dimension = preferredSize + override fun getScrollableUnitIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ) = UiStyle.Gap.pad() + override fun getScrollableBlockIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ) = visibleRect.height +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt index 9a37e556629..3fdfc126bb3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.ui.layout.align import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout import javax.swing.JComponent import javax.swing.JPanel @@ -20,9 +21,10 @@ class SettingsRow( ) : JPanel(BorderLayout()) { private val titleLabel = JBLabel(title).apply { font = UiStyle.Fonts.bold() } - private val descriptionLabel = JBLabel(description.orEmpty()).apply { + private val descriptionLabel = JBLabel(descriptionHtml(description)).apply { font = UiStyle.Fonts.hint() foreground = UIUtil.getContextHelpForeground() + setAllowAutoWrapping(true) isVisible = description != null } private val labels = Stack.vertical(UiStyle.Gap.sm()) @@ -45,7 +47,7 @@ class SettingsRow( value: JComponent? = null, ) { if (titleLabel.text != title) titleLabel.text = title - val text = description.orEmpty() + val text = descriptionHtml(description) if (descriptionLabel.text != text) descriptionLabel.text = text val visible = description != null if (descriptionLabel.isVisible != visible) descriptionLabel.isVisible = visible @@ -53,14 +55,22 @@ class SettingsRow( } private fun setValue(value: JComponent?) { - if (value != null && current !== value) { - valuePanel.removeAll() - valuePanel.add(value.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.CENTER) - current = value + if (current === value) return + valuePanel.removeAll() + current = value + if (value != null) { + valuePanel.add(value.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) } + valuePanel.revalidate() + valuePanel.repaint() } } +private fun descriptionHtml(description: String?): String { + val text = description ?: return "" + return XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) +} + class SettingsRows : Stack(StackAxis.VERTICAL) { private val keyed = linkedMapOf() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt index 93fcd7f1a18..3b5a1bc1795 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt @@ -18,6 +18,7 @@ import com.intellij.notification.NotificationType import com.intellij.notification.Notifications import com.intellij.openapi.application.ApplicationManager import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.InlineBanner import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -139,6 +140,41 @@ class ModelsSettingsUiTest : BasePlatformTestCase() { assertTrue(notes.any { it.title == "Failed to save model settings" }) } + fun `test logged out save keeps login banner stable`() { + edt { + requireUi().dispose() + ui = null + } + uiScope = CoroutineScope(SupervisorJob()) + val state = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/old"), + profile = null, + ) + rpc.state.value = state + app._state.value = state + workspaceRpc.models = ModelsWorkspaceDto(providers = providers()) + edt { ui = ModelsSettingsUi(uiScope, app, workspaces, directory = "/test") } + val panel = requireUi() + flushUntil { text(panel).contains("Old") && text(panel).contains("Sign in to Kilo Code") } + val banner = edt { components(panel.top).filterIsInstance().single() } + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + assertTrue(text(panel).contains("Sign in to Kilo Code")) + assertSame(banner, components(panel.top).filterIsInstance().single()) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + edt { + assertTrue(text(panel).contains("Sign in to Kilo Code")) + assertSame(banner, components(panel.top).filterIsInstance().single()) + } + } + private fun providers(): ProvidersDto = ProvidersDto( providers = listOf( ProviderDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt index a491df9fa92..fa9379721e5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt @@ -12,6 +12,9 @@ import javax.swing.AbstractButton import javax.swing.JButton import javax.swing.JComponent import javax.swing.JLabel +import javax.swing.JScrollPane +import javax.swing.ScrollPaneConstants +import javax.swing.Scrollable import javax.swing.text.JTextComponent class SettingsRowsTest : BasePlatformTestCase() { @@ -39,6 +42,42 @@ class SettingsRowsTest : BasePlatformTestCase() { assertTrue(text(row).contains("After")) } + fun `test keyed update can clear value`() { + val rows = SettingsRows() + val value = JButton("A") + val row = rows.row("one", SettingsRow("One", value = value)) + + rows.update("one", "One") + + assertFalse(components(row).any { it === value }) + } + + fun `test row value centers vertically`() { + val value = JButton("Choose a model") + val row = SettingsRow( + "Default model", + "This description is intentionally long enough to wrap instead of pushing the value off screen.", + value, + ) + row.setSize(row.preferredSize.width, row.preferredSize.height) + + layout(row) + + assertEquals((value.parent.height - value.height) / 2, value.y) + } + + fun `test row description uses escaped wrapping html`() { + val row = SettingsRow("Mode", "Use & safe models") + + val label = components(row) + .filterIsInstance() + .single { it.text.contains("fast") } + + assertTrue(label.text.startsWith("")) + assertTrue(label.text.contains("<fast>")) + assertTrue(label.text.contains("&")) + } + fun `test removing keyed row removes only that row`() { val rows = SettingsRows() val one = rows.row("one", SettingsRow("One", value = JButton("A"))) @@ -82,6 +121,17 @@ class SettingsRowsTest : BasePlatformTestCase() { assertTrue(text(panel.progress).contains("Loading models...")) } + fun `test settings panel tracks viewport width without horizontal scroll`() { + val panel = SettingsPanel() + + val scroll = components(panel).filterIsInstance().single() + val view = scroll.viewport.view + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, scroll.horizontalScrollBarPolicy) + assertTrue(view is Scrollable) + assertTrue((view as Scrollable).getScrollableTracksViewportWidth()) + } + fun `test settings progress overlay is centered near top`() { val panel = SettingsPanel().apply { setSize(400, 300) } @@ -173,6 +223,11 @@ class SettingsRowsTest : BasePlatformTestCase() { assertFalse("Expected colors to differ: $a", a.rgb == b.rgb) } + private fun layout(component: JComponent) { + component.doLayout() + components(component).filterIsInstance().forEach { it.doLayout() } + } + private fun text(component: JComponent): String = components(component) .mapNotNull { when (it) { From 709efd09a221f710bc32f9abb76b1d389ed4d629 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 14:38:14 -0400 Subject: [PATCH 064/201] fix(kilo-jetbrains): log settings saves --- .../backend/app/KiloBackendAppService.kt | 11 +++++++++- .../ai/kilocode/client/app/KiloAppService.kt | 9 +++++++- .../settings/models/ModelsSettingsUi.kt | 21 ++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 9846109f157..2a4d6ad350d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -217,6 +217,8 @@ class KiloBackendAppService private constructor( val http = connection.apiClient ?: throw IllegalStateException("Not connected") val current = _appState.value as? KiloAppState.Ready ?: throw IllegalStateException("Kilo backend is not ready") val body = KiloCliDataParser.buildConfigPatch(patch) + val summary = summary(patch) + log.info("Global config patch: started $summary") val request = Request.Builder() .url("http://127.0.0.1:$port/global/config") .header("Accept", "application/json") @@ -226,12 +228,14 @@ class KiloBackendAppService private constructor( http.newCall(request).execute().use { response -> if (!response.isSuccessful) { val text = response.body?.string() - log.warn("Global config patch failed: HTTP ${response.code} ${response.message} ${text.orEmpty()}") + log.warn("Global config patch failed: HTTP ${response.code} ${response.message} $summary ${text.orEmpty()}") throw IllegalStateException("Global config patch failed: HTTP ${response.code} ${response.message}") } } } + log.info("Global config patch: saved $summary") refreshConfigState() + log.info("Global config patch: state refreshed $summary") return (_appState.value as? KiloAppState.Ready) ?: current } @@ -809,6 +813,11 @@ class KiloBackendAppService private constructor( } } +private fun summary(patch: ConfigPatchDto): String { + val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" } + return "values=$values agents=${patch.agents.size}" +} + /** * Result of a data fetch — either a value or an error with details. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 38f7a1d8dff..d18532b7394 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -216,11 +216,13 @@ class KiloAppService internal constructor( } suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto? = try { + LOG.info("config update: sending RPC ${summary(patch)}") val next = call { updateConfig(patch) } _state.value = next + LOG.info("config update: RPC completed ${summary(patch)}") next } catch (e: Exception) { - LOG.warn("config update failed", e) + LOG.warn("config update failed ${summary(patch)}", e) null } @@ -310,3 +312,8 @@ class KiloAppService internal constructor( _state.value = current.copy(profile = profile, progress = progress) } } + +private fun summary(patch: ConfigPatchDto): String { + val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" } + return "values=$values agents=${patch.agents.size}" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index 6bc942b5a05..958053e5619 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -17,7 +17,9 @@ import ai.kilocode.client.settings.ui.SettingsRows import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align +import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.LoadErrorDto @@ -48,6 +50,10 @@ internal class ModelsSettingsUi( private val directory: String? = null, ) : SettingsPanel() { + companion object { + private val LOG = KiloLog.create(ModelsSettingsUi::class.java) + } + private val form = ModelsSettingsContent(app, ::update, ::selectSubagent) private val defaults = form.defaults private val small = form.small @@ -100,6 +106,7 @@ internal class ModelsSettingsUi( val next = draft val patch = patch(prev, next) if (patch.values.isEmpty() && patch.agents.isEmpty()) return + LOG.info("model settings save: started ${summary(patch)}") pending = next saving = true saveError = null @@ -108,10 +115,16 @@ internal class ModelsSettingsUi( app.updateConfigAsync(patch) { state -> ApplicationManager.getApplication().invokeLater({ if (disposed) { - if (state == null) KiloNotifications.error(KiloBundle.message("settings.models.save.failed")) + if (state == null) { + LOG.warn("model settings save: failed after dispose ${summary(patch)}") + KiloNotifications.error(KiloBundle.message("settings.models.save.failed")) + } else { + LOG.info("model settings save: completed after dispose ${summary(patch)}") + } return@invokeLater } if (state != null) { + LOG.info("model settings save: completed ${summary(patch)}") appState = state val base = modelsDraft(state.config, agents) baseline = if (savedMatches(base, next)) base else next @@ -127,6 +140,7 @@ internal class ModelsSettingsUi( draft = next pending = null saving = false + LOG.warn("model settings save: failed ${summary(patch)}") saveError = KiloBundle.message("settings.models.save.failed") sync() }, ModalityState.any()) @@ -391,6 +405,11 @@ internal class ModelsSettingsUi( private const val KILO_PROVIDER = "kilo" +private fun summary(patch: ConfigPatchDto): String { + val values = patch.values.keys.sorted().joinToString(",").ifEmpty { "none" } + return "values=$values agents=${patch.agents.size}" +} + private class ModelsSettingsContent( app: KiloAppService, update: (ModelsDraft.() -> ModelsDraft) -> Unit, From 9d43f4dde6155cbca2a9d16c30543bad6fb5971e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 15:59:07 -0400 Subject: [PATCH 065/201] fix(kilo-jetbrains): stabilize model settings saves --- .changeset/quiet-garden-model-settings.md | 2 +- .../settings/models/ModelsSettingsUi.kt | 23 +-- .../settings/models/ModelsSettingsUiTest.kt | 149 +++++++++++++++++- .../client/testing/FakeWorkspaceRpcApi.kt | 3 + 4 files changed, 165 insertions(+), 12 deletions(-) diff --git a/.changeset/quiet-garden-model-settings.md b/.changeset/quiet-garden-model-settings.md index 25be5c853d5..64a3ee054f7 100644 --- a/.changeset/quiet-garden-model-settings.md +++ b/.changeset/quiet-garden-model-settings.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Fix model settings layout and prevent login banner flicker while saving. +Fix model settings layout and keep pending model selections visible while saving. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index 958053e5619..e73532413d8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -87,13 +87,13 @@ internal class ModelsSettingsUi( @RequiresEdt fun modified(): Boolean { checkEdt() - return draft != baseline + return draft != (pending ?: baseline) } @RequiresEdt fun resetDraft() { checkEdt() - draft = baseline + draft = pending ?: baseline saveError = null if (!saving) clearProgress() sync() @@ -125,10 +125,11 @@ internal class ModelsSettingsUi( } if (state != null) { LOG.info("model settings save: completed ${summary(patch)}") + val edit = draft appState = state val base = modelsDraft(state.config, agents) baseline = if (savedMatches(base, next)) base else next - draft = next + draft = if (edit == next) baseline else edit pending = null saving = false saveError = null @@ -136,8 +137,9 @@ internal class ModelsSettingsUi( sync() return@invokeLater } + val edit = draft baseline = prev - draft = next + draft = if (edit == next) next else edit pending = null saving = false LOG.warn("model settings save: failed ${summary(patch)}") @@ -243,6 +245,7 @@ internal class ModelsSettingsUi( saving = saving, ) val ready = state == ModelsStatus.READY || state == ModelsStatus.MODES_FAILED + val editable = !saving && (ready || state == ModelsStatus.LOADING) val bannerVisible = modelsLoginBannerVisible( ready = appState.status == KiloAppStatusDto.READY, authenticated = appState.profile != null, @@ -262,9 +265,9 @@ internal class ModelsSettingsUi( defaults.setItems(allItems, draft.model) small.setItems(smallItems, draft.small) subagent.setItems(allItems, draft.subagent) - listOf(defaults, small, subagent).forEach { it.isEnabled = ready } - layout = syncVariant(ready) || layout - layout = syncModes(ready) || layout + listOf(defaults, small, subagent).forEach { it.isEnabled = editable } + layout = syncVariant(editable) || layout + layout = syncModes(editable) || layout if (layout) { revalidate() repaint() @@ -388,14 +391,14 @@ internal class ModelsSettingsUi( private fun acceptBase(base: ModelsDraft) { val save = pending if (save == null) { + val prev = baseline + val edit = draft baseline = base - if (!saving) draft = base + if (edit == prev) draft = base return } if (!savedMatches(base, save)) return baseline = base - draft = save - pending = null } private fun checkEdt() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt index 3b5a1bc1795..d2decf1199c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt @@ -103,6 +103,141 @@ class ModelsSettingsUiTest : BasePlatformTestCase() { flushUntil { !text(panel).contains("Failed to save model settings") } } + fun `test reset during pending save keeps applied selection visible`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + panel.resetDraft() + assertTrue(text(panel.progress).contains("Saving model settings")) + assertFalse(panel.modified()) + assertSelected(panel, "kilo/new") + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + edt { assertSelected(panel, "kilo/new") } + } + + fun `test pickers are disabled during pending save`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + assertTrue(pickers(panel).isNotEmpty()) + assertTrue(pickers(panel).all { !it.isEnabled }) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + } + + fun `test matching app state before save callback keeps pending target`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + } + rpc.state.value = state("kilo/new") + flushUntil { edt { !panel.modified() && pickers(panel).all { !it.isEnabled } } } + edt { + panel.resetDraft() + assertSelected(panel, "kilo/new") + assertTrue(text(panel.progress).contains("Saving model settings")) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + edt { assertSelected(panel, "kilo/new") } + } + + fun `test matching app state after save callback preserves dirty draft`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + select(panel, "old") + } + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + rpc.state.value = state("kilo/new") + flushUntil { edt { panel.modified() } } + + edt { + assertSelected(panel, "kilo/old") + assertTrue(panel.modified()) + } + } + + fun `test stale app state during pending save is ignored`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + } + rpc.state.value = state("kilo/old") + flushUntil { edt { text(panel.progress).contains("Saving model settings") } } + + edt { + assertSelected(panel, "kilo/new") + assertFalse(panel.modified()) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + } + + fun `test pickers stay enabled while models load`() { + edt { + requireUi().dispose() + ui = null + } + uiScope = CoroutineScope(SupervisorJob()) + workspaceRpc.modelsGate = CompletableDeferred() + workspaceRpc.models = ModelsWorkspaceDto(providers = providers()) + + edt { ui = ModelsSettingsUi(uiScope, app, workspaces, directory = "/test") } + val panel = requireUi() + + flushUntil { text(panel.progress).contains("Loading models") } + edt { + assertTrue(pickers(panel).isNotEmpty()) + assertTrue(pickers(panel).all { it.isEnabled }) + } + + workspaceRpc.modelsGate?.complete(Unit) + flushUntil { text(panel).contains("Old") } + } + + fun `test edit during pending save is preserved after completion`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + select(panel, "new") + panel.applyDraft() + select(panel, "old") + assertSelected(panel, "kilo/old") + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + edt { + assertSelected(panel, "kilo/old") + assertTrue(panel.modified()) + } + } + fun `test failed save after dispose shows notification`() { val notes = mutableListOf() ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe( @@ -190,11 +325,23 @@ class ModelsSettingsUiTest : BasePlatformTestCase() { defaults = emptyMap(), ) + private fun state(model: String): KiloAppStateDto = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = model), + profile = ProfileDto(email = "alice@test.com"), + ) + private fun select(panel: ModelsSettingsUi, id: String) { - val picker = components(panel).filterIsInstance().first() + val picker = pickers(panel).first() picker.onSelect(ModelPicker.Item(id, id.replaceFirstChar { it.titlecase() }, "kilo", "Kilo")) } + private fun assertSelected(panel: ModelsSettingsUi, key: String) { + assertEquals(key, pickers(panel).first().selectionKeyForTest()) + } + + private fun pickers(panel: ModelsSettingsUi): List = components(panel).filterIsInstance() + private fun requireUi(): ModelsSettingsUi = requireNotNull(ui) private fun edt(block: () -> T): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 8e1565a9a63..827307f5be8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -23,6 +24,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var reloads = 0 private set var models = ModelsWorkspaceDto() + var modelsGate: CompletableDeferred? = null var fileMatches = emptyList() var openResult = true val fileCalls = mutableListOf>() @@ -45,6 +47,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { override suspend fun models(directory: String): ModelsWorkspaceDto { assertNotEdt("models") + modelsGate?.await() return models } From da86d255b0c7ed39b5fdf236c2a5595af54f86dc Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 16:50:40 -0400 Subject: [PATCH 066/201] refactor(kilo-jetbrains): extract base settings UI --- .../BaseContentPanel.kt} | 4 +- .../client/settings/base/BaseSettingsUi.kt | 184 ++++++++++++++ .../settings/{ui => base}/SettingsPanel.kt | 2 +- .../{ui => base}/SettingsProgressOverlay.kt | 2 +- .../settings/{ui => base}/SettingsRow.kt | 2 +- .../settings/{ui => base}/SettingsTop.kt | 2 +- .../settings/models/ModelsSettingsUi.kt | 240 +++++------------- .../profile/UserProfileConfigurable.kt | 2 +- .../settings/base/BaseSettingsUiTest.kt | 234 +++++++++++++++++ .../settings/{ui => base}/SettingsRowsTest.kt | 2 +- 10 files changed, 489 insertions(+), 185 deletions(-) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/{ui/SettingsContentPanel.kt => base/BaseContentPanel.kt} (85%) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/{ui => base}/SettingsPanel.kt (98%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/{ui => base}/SettingsProgressOverlay.kt (98%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/{ui => base}/SettingsRow.kt (98%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/{ui => base}/SettingsTop.kt (98%) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/{ui => base}/SettingsRowsTest.kt (99%) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseContentPanel.kt similarity index 85% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseContentPanel.kt index 9a661d69922..3dca098631f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseContentPanel.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.settings.ui +package ai.kilocode.client.settings.base import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack @@ -7,7 +7,7 @@ import com.intellij.ui.TitledSeparator import com.intellij.ui.components.JBLabel import com.intellij.util.ui.UIUtil -internal open class SettingsContentPanel : Stack(StackAxis.VERTICAL) { +internal open class BaseContentPanel : Stack(StackAxis.VERTICAL) { fun section(title: String, description: String? = null): SettingsRows { next(TitledSeparator(title)) if (description != null) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt new file mode 100644 index 00000000000..efbf0ec2d5a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt @@ -0,0 +1,184 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.ide.DataManager +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableWithId +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.options.ex.Settings +import com.intellij.openapi.project.ProjectManager +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import java.util.function.Predicate +import javax.swing.JComponent + +internal abstract class BaseSettingsUi( + private val cs: CoroutineScope, + initial: D, + private val loginBanner: Boolean = true, +) : SettingsPanel() { + protected lateinit var form: C + private set + protected val jobs = mutableListOf() + protected var draft = initial + protected val saving get() = save + protected val saveError get() = error + + private var baseline = initial + private var pending: D? = null + private var save = false + private var error: String? = null + private var disposed = false + + protected fun setSettingsContent(content: C) { + form = content + setContent(content) + } + + @RequiresEdt + fun modified(): Boolean { + checkEdt() + return draft != (pending ?: baseline) + } + + @RequiresEdt + fun resetDraft() { + checkEdt() + draft = pending ?: baseline + error = null + if (!save) clearProgress() + syncContent() + } + + @RequiresEdt + fun applyDraft() { + checkEdt() + val prev = baseline + val next = draft + val change = change(prev, next) ?: return + logSaveStarted(change) + pending = next + save = true + error = null + showProgress(pendingText()) + syncContent() + save(change) { result -> + ApplicationManager.getApplication().invokeLater({ + if (disposed) { + if (result == null) { + logSaveFailedAfterDispose(change) + onSaveFailedAfterDispose(change) + } else { + logSaveCompletedAfterDispose(change) + } + return@invokeLater + } + if (result != null) { + logSaveCompleted(change) + val edit = draft + val base = base(result) + baseline = if (saved(base, next)) base else next + draft = if (edit == next) baseline else edit + pending = null + save = false + error = null + clearProgress() + syncContent() + return@invokeLater + } + val edit = draft + baseline = prev + draft = if (edit == next) next else edit + pending = null + save = false + error = failedText() + logSaveFailed(change) + syncContent() + }, ModalityState.any()) + } + } + + @RequiresEdt + fun dispose() { + checkEdt() + disposed = true + jobs.forEach { it.cancel() } + jobs.clear() + cs.cancel() + } + + @RequiresEdt + protected fun updateDraft(fn: D.() -> D) { + checkEdt() + draft = draft.fn() + error = null + syncContent() + } + + protected fun acceptBase(base: D) { + val target = pending + if (target == null) { + val prev = baseline + val edit = draft + baseline = base + if (edit == prev) draft = base + return + } + if (!saved(base, target)) return + baseline = base + } + + @RequiresEdt + protected fun syncLoginBanner(login: Boolean, fallback: () -> Unit) { + checkEdt() + if (loginBanner && login) { + top.showNotLoggedIn { openProfile(it) } + return + } + fallback() + } + + protected fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "Settings UI updates must run on EDT" } + } + + protected abstract fun change(from: D, to: D): P? + protected abstract fun save(change: P, done: (R?) -> Unit) + protected abstract fun base(result: R): D + protected abstract fun syncContent() + protected abstract fun pendingText(): String + protected abstract fun failedText(): String + + protected open fun saved(base: D, draft: D): Boolean = base == draft + protected open fun onSaveFailedAfterDispose(change: P) = KiloNotifications.error(failedText()) + protected open fun logSaveStarted(change: P) = Unit + protected open fun logSaveCompleted(change: P) = Unit + protected open fun logSaveFailed(change: P) = Unit + protected open fun logSaveFailedAfterDispose(change: P) = Unit + protected open fun logSaveCompletedAfterDispose(change: P) = Unit + + private fun openProfile(src: JComponent) { + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) + if (settings != null) { + val cfg = settings.find(UserProfileConfigurable.ID) + if (cfg != null) { + settings.select(cfg) + return + } + } + + val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault } + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID + }, + { cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) }, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt index 40e75393832..94dae721725 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPanel.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.settings.ui +package ai.kilocode.client.settings.base import ai.kilocode.client.ui.LayeredOverlayPanel import ai.kilocode.client.ui.UiStyle diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt index 25b7664f3d6..a663c1d762d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.settings.ui +package ai.kilocode.client.settings.base import ai.kilocode.client.ui.UiStyle import com.intellij.ui.components.JBLabel diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsRow.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsRow.kt index 3fdfc126bb3..d2763fbab23 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsRow.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.settings.ui +package ai.kilocode.client.settings.base import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsTop.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsTop.kt index e34c7a8b01b..a9203cac3a6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsTop.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.settings.ui +package ai.kilocode.client.settings.base import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.UiStyle diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index e73532413d8..9815425415c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -1,19 +1,17 @@ package ai.kilocode.client.settings.models -import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.model.ModelText -import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.client.settings.profile.edt -import ai.kilocode.client.settings.ui.SettingsBannerKind -import ai.kilocode.client.settings.ui.SettingsContentPanel -import ai.kilocode.client.settings.ui.SettingsPanel -import ai.kilocode.client.settings.ui.SettingsRow -import ai.kilocode.client.settings.ui.SettingsRows +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.BaseSettingsUi +import ai.kilocode.client.settings.base.SettingsBannerKind +import ai.kilocode.client.settings.base.SettingsRow +import ai.kilocode.client.settings.base.SettingsRows import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align @@ -25,46 +23,30 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.ProvidersDto -import com.intellij.ide.DataManager -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service -import com.intellij.openapi.options.Configurable -import com.intellij.openapi.options.ConfigurableWithId -import com.intellij.openapi.options.ShowSettingsUtil -import com.intellij.openapi.options.ex.Settings -import com.intellij.openapi.project.ProjectManager import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.function.Predicate -import javax.swing.JComponent internal class ModelsSettingsUi( private val cs: CoroutineScope, private val app: KiloAppService = service(), private val workspaces: KiloWorkspaceService = service(), private val directory: String? = null, -) : SettingsPanel() { +) : BaseSettingsUi(cs, ModelsDraft()) { companion object { private val LOG = KiloLog.create(ModelsSettingsUi::class.java) } - private val form = ModelsSettingsContent(app, ::update, ::selectSubagent) - private val defaults = form.defaults - private val small = form.small - private val subagent = form.subagent - private val variant = form.variant - private val variantRow = form.variantRow - private val pickers = form.pickers - private val jobs = mutableListOf() + private val defaults get() = form.defaults + private val small get() = form.small + private val subagent get() = form.subagent + private val variant get() = form.variant + private val variantRow get() = form.variantRow + private val pickers get() = form.pickers - private var baseline = ModelsDraft() - private var draft = ModelsDraft() private var providers: ProvidersDto? = null private var agents: List = emptyList() private var appState: KiloAppStateDto = app.state.value @@ -73,82 +55,42 @@ internal class ModelsSettingsUi( private var loaded = false private var errors: List = emptyList() private var allItems: List = emptyList() - private var saving = false - private var pending: ModelsDraft? = null - private var saveError: String? = null - private var disposed = false init { - setContent(form) - sync() + setSettingsContent(ModelsSettingsContent(app, { updateDraft(it) }, ::selectSubagent)) + syncContent() start() } - @RequiresEdt - fun modified(): Boolean { - checkEdt() - return draft != (pending ?: baseline) + override fun change(from: ModelsDraft, to: ModelsDraft): ConfigPatchDto? = patch(from, to).takeIf { + it.values.isNotEmpty() || it.agents.isNotEmpty() } - @RequiresEdt - fun resetDraft() { - checkEdt() - draft = pending ?: baseline - saveError = null - if (!saving) clearProgress() - sync() + override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) { + app.updateConfigAsync(change, done) } - @RequiresEdt - fun applyDraft() { - checkEdt() - val prev = baseline - val next = draft - val patch = patch(prev, next) - if (patch.values.isEmpty() && patch.agents.isEmpty()) return - LOG.info("model settings save: started ${summary(patch)}") - pending = next - saving = true - saveError = null - showProgress(KiloBundle.message("settings.models.save.pending")) - sync() - app.updateConfigAsync(patch) { state -> - ApplicationManager.getApplication().invokeLater({ - if (disposed) { - if (state == null) { - LOG.warn("model settings save: failed after dispose ${summary(patch)}") - KiloNotifications.error(KiloBundle.message("settings.models.save.failed")) - } else { - LOG.info("model settings save: completed after dispose ${summary(patch)}") - } - return@invokeLater - } - if (state != null) { - LOG.info("model settings save: completed ${summary(patch)}") - val edit = draft - appState = state - val base = modelsDraft(state.config, agents) - baseline = if (savedMatches(base, next)) base else next - draft = if (edit == next) baseline else edit - pending = null - saving = false - saveError = null - clearProgress() - sync() - return@invokeLater - } - val edit = draft - baseline = prev - draft = if (edit == next) next else edit - pending = null - saving = false - LOG.warn("model settings save: failed ${summary(patch)}") - saveError = KiloBundle.message("settings.models.save.failed") - sync() - }, ModalityState.any()) - } + override fun base(result: KiloAppStateDto): ModelsDraft { + appState = result + return modelsDraft(result.config, agents) } + override fun saved(base: ModelsDraft, draft: ModelsDraft): Boolean = savedMatches(base, draft) + + override fun pendingText(): String = KiloBundle.message("settings.models.save.pending") + + override fun failedText(): String = KiloBundle.message("settings.models.save.failed") + + override fun logSaveStarted(change: ConfigPatchDto) = LOG.info("model settings save: started ${summary(change)}") + + override fun logSaveCompleted(change: ConfigPatchDto) = LOG.info("model settings save: completed ${summary(change)}") + + override fun logSaveFailed(change: ConfigPatchDto) = LOG.warn("model settings save: failed ${summary(change)}") + + override fun logSaveFailedAfterDispose(change: ConfigPatchDto) = LOG.warn("model settings save: failed after dispose ${summary(change)}") + + override fun logSaveCompletedAfterDispose(change: ConfigPatchDto) = LOG.info("model settings save: completed after dispose ${summary(change)}") + @RequiresEdt fun updateApp(state: KiloAppStateDto) { checkEdt() @@ -159,12 +101,12 @@ internal class ModelsSettingsUi( agents = emptyList() errors = emptyList() } - sync() + syncContent() return } val base = modelsDraft(state.config, agents) acceptBase(base) - sync() + syncContent() loadModels() } @@ -178,22 +120,13 @@ internal class ModelsSettingsUi( loading = false val base = modelsDraft(appState.config, agents) acceptBase(base) - sync() + syncContent() } @RequiresEdt fun updateModels(state: ai.kilocode.rpc.dto.ModelStateDto) { checkEdt() - sync() - } - - @RequiresEdt - fun dispose() { - checkEdt() - disposed = true - jobs.forEach { it.cancel() } - jobs.clear() - cs.cancel() + syncContent() } private fun start() { @@ -211,7 +144,7 @@ internal class ModelsSettingsUi( dir = resolved loaded = false loadModels() - sync() + syncContent() } } } @@ -223,7 +156,7 @@ internal class ModelsSettingsUi( if (appState.status != KiloAppStatusDto.READY || loading || loaded) return loading = true errors = emptyList() - sync() + syncContent() jobs += cs.launch { val state = workspaces.models(root) withContext(edt) { updateModelsWorkspace(state) } @@ -231,7 +164,7 @@ internal class ModelsSettingsUi( } @RequiresEdt - private fun sync() { + override fun syncContent() { checkEdt() allItems = items(false) val smallItems = items(true) @@ -250,7 +183,7 @@ internal class ModelsSettingsUi( ready = appState.status == KiloAppStatusDto.READY, authenticated = appState.profile != null, ) - syncBanner(state, bannerVisible) + syncModelBanner(state, bannerVisible) val err = saveError if (saving || state == ModelsStatus.SAVING) { showProgress(KiloBundle.message("settings.models.save.pending")) @@ -275,22 +208,20 @@ internal class ModelsSettingsUi( } @RequiresEdt - private fun syncBanner(state: ModelsStatus, login: Boolean) { + private fun syncModelBanner(state: ModelsStatus, login: Boolean) { checkEdt() - if (login) { - top.showNotLoggedIn { openProfile(it) } - return - } - if ((saving || state == ModelsStatus.LOADING || state == ModelsStatus.SAVING) && top.isVisible) return - when (state) { - ModelsStatus.LOAD_FAILED -> top.showBanner( - KiloBundle.message("settings.models.load.failed"), - emptyList(), - SettingsBannerKind.ERROR, - ) - ModelsStatus.NO_PROVIDERS -> top.showBanner(KiloBundle.message("settings.models.noProviders"), emptyList()) - ModelsStatus.MODES_FAILED -> top.showBanner(KiloBundle.message("settings.models.modes.failed"), emptyList()) - else -> top.hideBanner() + syncLoginBanner(login) { + if ((saving || state == ModelsStatus.LOADING || state == ModelsStatus.SAVING) && top.isVisible) return@syncLoginBanner + when (state) { + ModelsStatus.LOAD_FAILED -> top.showBanner( + KiloBundle.message("settings.models.load.failed"), + emptyList(), + SettingsBannerKind.ERROR, + ) + ModelsStatus.NO_PROVIDERS -> top.showBanner(KiloBundle.message("settings.models.noProviders"), emptyList()) + ModelsStatus.MODES_FAILED -> top.showBanner(KiloBundle.message("settings.models.modes.failed"), emptyList()) + else -> top.hideBanner() + } } } @@ -333,8 +264,8 @@ internal class ModelsSettingsUi( val picker = ModelSettingPicker() picker.picker.favorites = { app.favorites.value } picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) } - picker.picker.onSelect = { item -> update { copy(agents = this.agents + (agent.name to item.key)) } } - picker.picker.onClear = { update { copy(agents = this.agents + (agent.name to null)) } } + picker.picker.onSelect = { item -> updateDraft { copy(agents = this.agents + (agent.name to item.key)) } } + picker.picker.onClear = { updateDraft { copy(agents = this.agents + (agent.name to null)) } } pickers[agent.name] = picker form.modes.row(agent.name, SettingsRow( agent.displayName ?: title(agent.name), @@ -357,52 +288,7 @@ internal class ModelsSettingsUi( private fun selectSubagent(item: ModelPicker.Item) { val variant = if (draft.subagent == item.key && draft.variant in item.variants) draft.variant else item.variants.firstOrNull() - update { copy(subagent = item.key, variant = variant) } - } - - private fun openProfile(src: JComponent) { - val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) - if (settings != null) { - val cfg = settings.find(UserProfileConfigurable.ID) - if (cfg != null) { - settings.select(cfg) - return - } - } - - val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault } - ShowSettingsUtil.getInstance().showSettingsDialog( - project, - Predicate { cfg: Configurable -> - cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID - }, - { cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) }, - ) - } - - @RequiresEdt - private fun update(fn: ModelsDraft.() -> ModelsDraft) { - checkEdt() - draft = draft.fn() - saveError = null - sync() - } - - private fun acceptBase(base: ModelsDraft) { - val save = pending - if (save == null) { - val prev = baseline - val edit = draft - baseline = base - if (edit == prev) draft = base - return - } - if (!savedMatches(base, save)) return - baseline = base - } - - private fun checkEdt() { - check(ApplicationManager.getApplication().isDispatchThread) { "Models settings UI updates must run on EDT" } + updateDraft { copy(subagent = item.key, variant = variant) } } } @@ -413,11 +299,11 @@ private fun summary(patch: ConfigPatchDto): String { return "values=$values agents=${patch.agents.size}" } -private class ModelsSettingsContent( +internal class ModelsSettingsContent( app: KiloAppService, update: (ModelsDraft.() -> ModelsDraft) -> Unit, select: (ModelPicker.Item) -> Unit, -) : SettingsContentPanel() { +) : BaseContentPanel() { val defaults = ModelSettingPicker() val small = ModelSettingPicker() val subagent = ModelSettingPicker() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt index 44cfd7a1c38..2020bffca61 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.settings.ui.SettingsPanel +import ai.kilocode.client.settings.base.SettingsPanel import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt new file mode 100644 index 00000000000..88a4f265394 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt @@ -0,0 +1,234 @@ +package ai.kilocode.client.settings.base + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import java.awt.Container +import javax.swing.AbstractButton +import javax.swing.JLabel +import javax.swing.text.JTextComponent + +class BaseSettingsUiTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private var panel: FakePanel? = null + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + } + + override fun tearDown() { + try { + val view = panel + if (view != null) edt { view.dispose() } + panel = null + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test modified and reset use baseline`() { + val view = create() + + edt { + view.edit("new") + assertTrue(view.modified()) + view.resetDraft() + assertEquals("old", view.value()) + assertFalse(view.modified()) + } + } + + fun `test pending save target is not modified`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + assertFalse(view.modified()) + view.edit("other") + assertTrue(view.modified()) + } + } + + fun `test failed save keeps draft dirty and shows error`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + view.fail() + } + flush() + + edt { + assertEquals("new", view.value()) + assertTrue(view.modified()) + assertTrue(text(view.progress).contains("Failed")) + } + } + + fun `test edit clears save error`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + view.fail() + } + flush() + edt { view.edit("other") } + flush() + + edt { assertFalse(text(view.progress).contains("Failed")) } + } + + fun `test successful save preserves concurrent edit`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + view.edit("other") + view.succeed("new") + } + flush() + + edt { + assertEquals("other", view.value()) + assertTrue(view.modified()) + } + } + + fun `test failed save after dispose calls failure hook`() { + val view = create() + + edt { + view.edit("new") + view.applyDraft() + view.dispose() + view.fail() + } + panel = null + flush() + + assertEquals(1, view.disposedFailures) + } + + fun `test login banner can be shown`() { + val view = create() + + edt { view.banner(true) } + + edt { assertTrue(text(view).contains("Sign in to Kilo Code")) } + } + + fun `test login banner can be disabled`() { + val view = create(login = false) + + edt { view.banner(true) } + + edt { assertFalse(text(view).contains("Sign in to Kilo Code")) } + } + + private fun create(login: Boolean = true): FakePanel { + val view = edt { FakePanel(scope, login) } + panel = view + return view + } + + private fun flush() = runBlocking { + edt { UIUtil.dispatchAllInvocationEvents() } + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { out.add(it) } + is JLabel -> comp.text?.let { out.add(it) } + is JTextComponent -> comp.text?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } + + private data class Draft(val value: String) + private data class Change(val value: String) + + private class FakeContent : BaseContentPanel() + + private class FakePanel( + cs: CoroutineScope, + login: Boolean, + ) : BaseSettingsUi(cs, Draft("old"), login) { + private val callbacks = mutableListOf<(Draft?) -> Unit>() + var disposedFailures = 0 + private set + + init { + setSettingsContent(FakeContent()) + syncContent() + } + + fun edit(value: String) = updateDraft { copy(value = value) } + + fun value(): String = draft.value + + fun succeed(value: String) = callbacks.removeAt(0)(Draft(value)) + + fun fail() = callbacks.removeAt(0)(null) + + fun banner(login: Boolean) = syncLoginBanner(login) { top.hideBanner() } + + override fun change(from: Draft, to: Draft): Change? = if (from == to) null else Change(to.value) + + override fun save(change: Change, done: (Draft?) -> Unit) { + callbacks += done + } + + override fun base(result: Draft): Draft = result + + override fun syncContent() { + val err = saveError + if (saving) { + showProgress(pendingText()) + return + } + if (err != null) { + showError(err) + return + } + clearProgress() + } + + override fun pendingText(): String = "Saving" + + override fun failedText(): String = "Failed" + + override fun onSaveFailedAfterDispose(change: Change) { + disposedFailures++ + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt index fa9379721e5..3ee5427d607 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.settings.ui +package ai.kilocode.client.settings.base import ai.kilocode.client.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase From b2a989f5c3374bf1dd27dfd8298dfd1ae590fe34 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 16:54:32 -0400 Subject: [PATCH 067/201] refactor(kilo-jetbrains): centralize settings EDT checks --- .../ai/kilocode/client/settings/base/BaseSettingsUi.kt | 2 +- .../ai/kilocode/client/settings/models/ModelsSettingsUi.kt | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt index efbf0ec2d5a..ebb7f1ed5ed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt @@ -143,7 +143,7 @@ internal abstract class BaseSettingsUi( fallback() } - protected fun checkEdt() { + private fun checkEdt() { check(ApplicationManager.getApplication().isDispatchThread) { "Settings UI updates must run on EDT" } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index 9815425415c..1f9a6e2b802 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -93,7 +93,6 @@ internal class ModelsSettingsUi( @RequiresEdt fun updateApp(state: KiloAppStateDto) { - checkEdt() appState = state if (state.status != KiloAppStatusDto.READY) { loading = false @@ -112,7 +111,6 @@ internal class ModelsSettingsUi( @RequiresEdt fun updateModelsWorkspace(state: ModelsWorkspaceDto) { - checkEdt() providers = state.providers agents = state.agents?.agents ?: emptyList() errors = state.errors @@ -125,7 +123,6 @@ internal class ModelsSettingsUi( @RequiresEdt fun updateModels(state: ai.kilocode.rpc.dto.ModelStateDto) { - checkEdt() syncContent() } @@ -151,7 +148,6 @@ internal class ModelsSettingsUi( @RequiresEdt private fun loadModels() { - checkEdt() val root = dir ?: return if (appState.status != KiloAppStatusDto.READY || loading || loaded) return loading = true @@ -165,7 +161,6 @@ internal class ModelsSettingsUi( @RequiresEdt override fun syncContent() { - checkEdt() allItems = items(false) val smallItems = items(true) val hasDir = dir != null || directory != null @@ -209,7 +204,6 @@ internal class ModelsSettingsUi( @RequiresEdt private fun syncModelBanner(state: ModelsStatus, login: Boolean) { - checkEdt() syncLoginBanner(login) { if ((saving || state == ModelsStatus.LOADING || state == ModelsStatus.SAVING) && top.isVisible) return@syncLoginBanner when (state) { From d001785626975649504a542c795ac0d3e2ede703 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 17:10:07 -0400 Subject: [PATCH 068/201] fix(jetbrains): preserve streamed chat markdown --- .changeset/retained-jetbrains-markdown.md | 2 +- .../app/KiloBackendConnectionService.kt | 30 +++++-- .../backend/app/KiloConnectionServiceTest.kt | 80 +++++++++++++++++++ .../session/controller/SessionController.kt | 33 ++++++-- .../controller/SessionUpdateQueueTest.kt | 48 +++++++++++ 5 files changed, 179 insertions(+), 14 deletions(-) diff --git a/.changeset/retained-jetbrains-markdown.md b/.changeset/retained-jetbrains-markdown.md index e5e6cb0b0c7..b01def7f0c5 100644 --- a/.changeset/retained-jetbrains-markdown.md +++ b/.changeset/retained-jetbrains-markdown.md @@ -2,4 +2,4 @@ "kilo-code": patch --- -Improve JetBrains chat streaming performance by retaining existing markdown and code block views while responses stream, and render code blocks without showing raw fence markers during streamed updates. +Improve JetBrains chat streaming performance by retaining existing markdown and code block views while responses stream, and keep streamed code fences intact without showing raw fence markers during updates. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index 4eccd0b4c74..c3349ce37bc 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -88,6 +89,11 @@ class KiloConnectionService( private val _events = MutableSharedFlow(extraBufferCapacity = 64) val events: SharedFlow = _events.asSharedFlow() + private val queue = Channel(Channel.UNLIMITED) + private val lock = Any() + private val eventJob = cs.launch { + for (event in queue) _events.emit(event) + } /** Generated API client — null when disconnected. */ var api: DefaultApi? = null @@ -232,25 +238,31 @@ class KiloConnectionService( } private val listener = object : EventSourceListener() { - override fun onOpen(src: EventSource, response: Response) { + override fun onOpen(eventSource: EventSource, response: Response) { log.info("SSE: connected") setState(ConnectionState.Connected(port, password)) lastEvent.set(System.currentTimeMillis()) } - override fun onEvent(src: EventSource, id: String?, type: String?, data: String) { - lastEvent.set(System.currentTimeMillis()) - val kind = type ?: KiloCliDataParser.extractEventType(data) - log.debug { "evt=$kind bytes=${data.length} hasId=${id != null} ${ChatLogSummary.body(data)}" } - cs.launch { _events.emit(SseEvent(type = kind, data = data)) } + override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) { + synchronized(lock) { + if (disposed) return@synchronized + lastEvent.set(System.currentTimeMillis()) + val kind = type ?: KiloCliDataParser.extractEventType(data) + log.debug { "evt=$kind bytes=${data.length} hasId=${id != null} ${ChatLogSummary.body(data)}" } + val result = queue.trySend(SseEvent(type = kind, data = data)) + if (result.isFailure && !disposed) { + log.warn("SSE: event queue rejected type=$kind", result.exceptionOrNull()) + } + } } - override fun onClosed(src: EventSource) { + override fun onClosed(eventSource: EventSource) { log.info("SSE: stream closed — scheduling reconnect") scheduleReconnect() } - override fun onFailure(src: EventSource, t: Throwable?, response: Response?) { + override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { val detail = when { t != null -> t.stackTraceToString() response != null -> response.body?.string() @@ -369,6 +381,8 @@ class KiloConnectionService( healthJob?.cancel() processJob?.cancel() reconnectJob?.cancel() + eventJob.cancel() + queue.close() close() _state.value = ConnectionState.Disconnected log.info("KiloConnectionService disposed") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index de84bfced22..3b62046dd36 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -6,15 +6,24 @@ import ai.kilocode.backend.app.KiloConnectionService import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog +import ai.kilocode.log.KiloLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import okhttp3.Request +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.test.AfterTest import kotlin.test.Test @@ -93,6 +102,61 @@ class KiloConnectionServiceTest { } } + @Test + fun `SSE events preserve callback order`() = runBlocking { + val svc = KiloConnectionService(scope, fake, {}, log) + svc.connect() + mock.awaitSseConnection() + + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + + val count = 100 + val received = async { + withTimeout(5_000) { + svc.events.take(count).toList() + } + } + + delay(200) + repeat(count) { idx -> + mock.pushEvent("test.event", idx.toString()) + } + + assertEquals((0 until count).map { it.toString() }, received.await().map { it.data }) + } + + @Test + fun `SSE concurrent callbacks preserve callback order`() = runBlocking { + val blocked = BlockingLog() + val svc = KiloConnectionService(scope, fake, {}, blocked) + val field = KiloConnectionService::class.java.getDeclaredField("listener") + field.isAccessible = true + val listener = field.get(svc) as EventSourceListener + val source = object : EventSource { + override fun request(): Request = Request.Builder().url("http://127.0.0.1/global/event").build() + override fun cancel() {} + } + val received = scope.async { + withTimeout(5_000) { + svc.events.take(2).toList() + } + } + + delay(200) + val first = Thread { listener.onEvent(source, null, "first.event", "first") } + val second = Thread { listener.onEvent(source, null, "second.event", "second") } + first.start() + assertTrue(blocked.started.await(1, TimeUnit.SECONDS)) + second.start() + first.join() + second.join() + + assertEquals(listOf("first", "second"), received.await().map { it.data }) + svc.dispose() + } + @Test fun `SSE close triggers error state`() = runBlocking { val svc = KiloConnectionService(scope, fake, {}, log) @@ -208,4 +272,20 @@ class KiloConnectionServiceTest { svc.state.first { it !is ConnectionState.Connected } } } + + private class BlockingLog : KiloLog { + val started = CountDownLatch(1) + override var isDebugEnabled: Boolean = true + + override fun debug(block: () -> String) { + val msg = block() + if (!msg.contains("evt=first.event")) return + started.countDown() + Thread.sleep(250) + } + + override fun info(msg: String) {} + override fun warn(msg: String, t: Throwable?) {} + override fun error(msg: String, t: Throwable?) {} + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 29adf9e05f6..a1c2076218e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -145,6 +145,9 @@ class SessionController( private var prefModel: String? = null private var prefAgent: String? = null private var modelTime: Double? = null + private val snapshots = mutableMapOf() + + private data class PartKey(val messageId: String, val partId: String) val ready: Boolean get() = model.isReady() val autoApprove: Boolean get() = KiloPluginSettings.getAutoApprove() @@ -653,6 +656,7 @@ class SessionController( if (disposed) return@runEdt if (sid != id) return@runEdt updateModel { + snapshots.clear() this@SessionController.model.loadHistory(items) syncHistoryAgent(items) if (session != null) this@SessionController.model.setSession(session) @@ -701,6 +705,7 @@ class SessionController( ref = SessionRef.Local(session) setRecentSessionsState(RecentsState.Idle) updateModel { + snapshots.clear() this@SessionController.model.loadHistory(items) syncHistoryAgent(items) this@SessionController.model.setSession(session) @@ -890,7 +895,15 @@ class SessionController( is ChatEventDto.PartUpdated -> { partType = event.part.type tool = event.part.tool + val key = PartKey(event.part.messageID, event.part.id) + val prev = content(event.part.messageID, event.part.id) model.updateContent(event.part.messageID, event.part) + val next = content(event.part.messageID, event.part.id) + if (next != null && next != prev) { + snapshots[key] = next + } else { + snapshots.remove(key) + } if (model.state is SessionState.Busy) { model.setState(SessionState.Busy(status())) } @@ -905,6 +918,7 @@ class SessionController( } is ChatEventDto.PartRemoved -> { + snapshots.remove(PartKey(event.messageID, event.partID)) model.removeContent(event.messageID, event.partID) } @@ -938,6 +952,7 @@ class SessionController( } is ChatEventDto.MessageRemoved -> { + snapshots.keys.removeAll { it.messageId == event.messageID } model.removeMessage(event.messageID) } @@ -982,16 +997,24 @@ class SessionController( private fun glue(messageId: String, partId: String, delta: String): String { if (delta.isEmpty()) return delta - val cur = when (val content = model.content(messageId, partId)) { - is Text -> content.content - is Reasoning -> content.content - else -> return delta - } + val key = PartKey(messageId, partId) + val cur = snapshots[key] ?: return delta val span = (minOf(cur.length, delta.length) downTo 1) .firstOrNull { n -> cur.regionMatches(cur.length - n, delta, 0, n) } ?: 0 + if (span == delta.length) { + snapshots.remove(key) + return "" + } + snapshots.remove(key) return delta.substring(span) } + private fun content(messageId: String, partId: String): String? = when (val content = model.content(messageId, partId)) { + is Text -> content.content.toString() + is Reasoning -> content.content.toString() + else -> null + } + private fun handleHidden(event: ChatEventDto): Boolean = when (event) { is ChatEventDto.Error, is ChatEventDto.PermissionAsked, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt index 11d4e92548b..1f08b695574 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt @@ -257,6 +257,54 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { ) } + fun `test pure text deltas preserve incidental overlap`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hel")) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "lo")) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + hello + """, + m, + ) + } + + fun `test pure text deltas preserve split closing fence`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "```python\nprint(1)\n``")) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "`\n\nafter")) + settle() + flush() + + assertModel( + """ + assistant#msg1 + text#prt1: + ```python + print(1) + ``` + + after + """, + m, + ) + } + fun `test text snapshot covered prefix is trimmed from merged delta`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() From fe4d6b940f4e87769fbf62e9fa0c64a602b54b95 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 17:13:22 -0400 Subject: [PATCH 069/201] docs(jetbrains): document session event logging --- AGENTS.md | 1 + packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml | 4 ++-- packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 56c25b60c1c..0a018627a2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang - **Dev**: `bun run dev` (runs from root) or `bun run --cwd packages/opencode --conditions=browser src/index.ts` - **Dev with params**: `bun dev -- help` +- **JetBrains session event logs**: use `script/dev/part-update.sh client ` to print frontend `message.part.delta` text by part id; use `script/dev/part-update.sh backend ` for backend sandbox events. Append with `>> file.txt` when you need to keep the output. For full chat payload previews in JetBrains dev runs, pass `-Pkilo.dev.log.chat.content=` where `` is `off` (default, no content), `preview` (cleaned/truncated content), or `full` (cleaned full content). `-Pkilo.dev.log.chat.preview.max=` controls preview length, clamped from 1 to 2000. - **Extension**: `bun run extension` (build + launch VS Code with the extension in dev mode). Pass `--no-build` to skip the build. - **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`). Includes the JetBrains plugin — requires Java 21. Check with `java -version` before running. If missing, install via SDKMAN: `sdk install java 21-tem && sdk use java 21-tem`. If SDKMAN is not installed, see https://sdkman.io/install. - **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests) diff --git a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml index 84c1fe34e03..f5fc396e538 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml @@ -5,7 +5,7 @@ @@ -25,4 +25,4 @@ false - + \ No newline at end of file diff --git a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml index 34b198f4e0a..3d43dad4c45 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml @@ -5,7 +5,7 @@ From 7adb134f2430d68f66c7e66d4c5555af3c489e56 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 17:15:20 -0400 Subject: [PATCH 070/201] chore(jetbrains): add session event log helper --- script/dev/part-update.sh | 135 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100755 script/dev/part-update.sh diff --git a/script/dev/part-update.sh b/script/dev/part-update.sh new file mode 100755 index 00000000000..fadb27500bf --- /dev/null +++ b/script/dev/part-update.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env sh +set -eu + +usage() { + printf 'Usage: %s \n' "$0" >&2 + printf ' %s \n' "$0" >&2 +} + +if [ "$#" -ne 2 ]; then + usage + exit 2 +fi + +target=$1 +sid=$2 +mode=$target + +script=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +root=$(CDPATH= cd -- "$script/../.." && pwd) +plugin="$root/packages/kilo-jetbrains" + +resolve() { + case "$target" in + client|frontend) + for file in \ + "$plugin/.intellijPlatform/sandbox/kilo.jetbrains/kilo-frontend/kilo-dev.log" \ + "$plugin/build/idea-sandbox/system/log/idea.log" \ + "$plugin/build/idea-sandbox/client/system/log/idea.log" \ + "$plugin/build/idea-sandbox/frontend/system/log/idea.log" + do + if [ -r "$file" ]; then + printf '%s\n' "$file" + return 0 + fi + done + ;; + backend) + for file in \ + "$plugin/.intellijPlatform/sandbox/kilo.jetbrains/kilo-backend/kilo-dev.log" \ + "$plugin/build/idea-sandbox/backend/system/log/idea.log" \ + "$plugin/build/idea-sandbox-backend/system/log/idea.log" \ + "$plugin/build/idea-sandbox/system/log/idea.log" + do + if [ -r "$file" ]; then + printf '%s\n' "$file" + return 0 + fi + done + ;; + *) + if [ -r "$target" ]; then + printf '%s\n' "$target" + return 0 + fi + ;; + esac + + return 1 +} + +in=$(resolve || true) + +if [ -z "$in" ]; then + printf 'Could not find readable log for target: %s\n' "$target" >&2 + printf 'Tried JetBrains sandbox logs under: %s/.intellijPlatform and %s/build\n' "$plugin" "$plugin" >&2 + exit 1 +fi + +if [ ! -r "$in" ]; then + printf 'Input not readable: %s\n' "$in" >&2 + exit 1 +fi + +awk -v want="$sid" -v mode="$mode" ' +function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s +} + +function value(s, key, pattern) { + pattern = key "=[^[:space:]]+" + if (match(s, pattern)) return substr(s, RSTART + length(key) + 1, RLENGTH - length(key) - 1) + return "" +} + +function quoted(s, key, pattern) { + pattern = key "=\"[^\"]*\"" + if (match(s, pattern)) return substr(s, RSTART + length(key) + 2, RLENGTH - length(key) - 3) + return "" +} + +function logger(line, start, rest, stop) { + start = index(line, " - #") + if (start == 0) return "" + rest = substr(line, start + 4) + stop = index(rest, " - ") + if (stop == 0) return "" + return substr(rest, 1, stop - 1) +} + +function message(line, start, rest, stop) { + start = index(line, " - #") + if (start == 0) return "" + rest = substr(line, start + 4) + stop = index(rest, " - ") + if (stop == 0) return "" + return substr(rest, stop + 3) +} + +function emit(msg, pid, text) { + if (value(msg, "sid") != want) return + if (value(msg, "evt") != "message.part.delta") return + if (value(msg, "field") != "text") return + + pid = value(msg, "pid") + text = quoted(msg, "preview") + if (pid == "" || text == "") return + + print "pid=" pid " text=\"" text "\"" +} + +{ + sub(/\r$/, "") + cls = logger($0) + if ((mode == "client" || mode == "frontend") && cls !~ /KiloSessionService$/) next + if (mode == "backend" && cls !~ /KiloBackendChatManager$/) next + + msg = trim(message($0)) + if (msg == "") next + if (msg ~ /pass=(true|false)/) next + + emit(msg) +} +' "$in" From b4ac8fac44af3af38fd41113551beaef61ffd718 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 17:19:45 -0400 Subject: [PATCH 071/201] refactor(kilo-jetbrains): move settings workspace lifecycle to base --- .../client/settings/base/BaseSettingsUi.kt | 30 ++- .../settings/base/BaseWorkspaceSettingsUi.kt | 127 ++++++++++++ .../settings/models/ModelsSettingsUi.kt | 115 +++-------- .../base/BaseWorkspaceSettingsUiTest.kt | 191 ++++++++++++++++++ 4 files changed, 378 insertions(+), 85 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUi.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUiTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt index ebb7f1ed5ed..ccafc479990 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt @@ -18,7 +18,7 @@ import java.util.function.Predicate import javax.swing.JComponent internal abstract class BaseSettingsUi( - private val cs: CoroutineScope, + protected val scope: CoroutineScope, initial: D, private val loginBanner: Boolean = true, ) : SettingsPanel() { @@ -109,7 +109,7 @@ internal abstract class BaseSettingsUi( disposed = true jobs.forEach { it.cancel() } jobs.clear() - cs.cancel() + scope.cancel() } @RequiresEdt @@ -120,7 +120,9 @@ internal abstract class BaseSettingsUi( syncContent() } + @RequiresEdt protected fun acceptBase(base: D) { + checkEdt() val target = pending if (target == null) { val prev = baseline @@ -147,19 +149,43 @@ internal abstract class BaseSettingsUi( check(ApplicationManager.getApplication().isDispatchThread) { "Settings UI updates must run on EDT" } } + @RequiresEdt protected abstract fun change(from: D, to: D): P? + + @RequiresEdt protected abstract fun save(change: P, done: (R?) -> Unit) + + @RequiresEdt protected abstract fun base(result: R): D + + @RequiresEdt protected abstract fun syncContent() + + @RequiresEdt protected abstract fun pendingText(): String + + @RequiresEdt protected abstract fun failedText(): String + @RequiresEdt protected open fun saved(base: D, draft: D): Boolean = base == draft + + @RequiresEdt protected open fun onSaveFailedAfterDispose(change: P) = KiloNotifications.error(failedText()) + + @RequiresEdt protected open fun logSaveStarted(change: P) = Unit + + @RequiresEdt protected open fun logSaveCompleted(change: P) = Unit + + @RequiresEdt protected open fun logSaveFailed(change: P) = Unit + + @RequiresEdt protected open fun logSaveFailedAfterDispose(change: P) = Unit + + @RequiresEdt protected open fun logSaveCompletedAfterDispose(change: P) = Unit private fun openProfile(src: JComponent) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUi.kt new file mode 100644 index 00000000000..17c1c4f92ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUi.kt @@ -0,0 +1,127 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ModelStateDto +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal abstract class BaseWorkspaceSettingsUi( + cs: CoroutineScope, + initial: D, + private val app: KiloAppService = service(), + private val workspaces: KiloWorkspaceService = service(), + private val hint: String? = null, + loginBanner: Boolean = true, +) : BaseSettingsUi(cs, initial, loginBanner) { + protected var appState: KiloAppStateDto = app.state.value + private set + protected var modelState: ModelStateDto = app.models.value + private set + protected var projectDirectory: String? = null + private set + protected val hasProjectDirectory get() = projectDirectory != null || hint != null + protected var workspaceLoading = false + private set + protected var workspaceLoaded = false + private set + + @RequiresEdt + protected fun startSettings(content: C) { + setSettingsContent(content) + syncContent() + start() + } + + private fun start() { + jobs += scope.launch { + app.state.collect { state -> withContext(edt) { updateApp(state) } } + } + jobs += scope.launch { + app.models.collect { state -> withContext(edt) { updateModels(state) } } + } + jobs += scope.launch { app.connect() } + val path = hint ?: return + jobs += scope.launch { + val dir = workspaces.resolveProjectDirectory(path) + withContext(edt) { + projectDirectory = dir + workspaceLoaded = false + syncContent() + load() + } + } + } + + @RequiresEdt + private fun updateApp(state: KiloAppStateDto) { + appState = state + if (state.status != KiloAppStatusDto.READY) { + workspaceLoading = false + unavailable(state) + syncContent() + return + } + acceptBase(draft(state)) + syncContent() + load() + } + + @RequiresEdt + private fun updateModels(state: ModelStateDto) { + modelState = state + models(state) + syncContent() + } + + @RequiresEdt + private fun load() { + val root = projectDirectory ?: return + if (appState.status != KiloAppStatusDto.READY || workspaceLoading || workspaceLoaded) return + workspaceLoading = true + clearWorkspaceError() + syncContent() + jobs += scope.launch { + val state = loadWorkspace(root) + withContext(edt) { + applyWorkspace(state) + workspaceLoaded = true + workspaceLoading = false + acceptBase(draft(appState)) + syncContent() + } + } + } + + @RequiresEdt + protected abstract fun draft(state: KiloAppStateDto): D + + @RequiresBackgroundThread + protected abstract suspend fun loadWorkspace(root: String): W + + @RequiresEdt + protected abstract fun applyWorkspace(result: W) + + @RequiresEdt + protected open fun unavailable(state: KiloAppStateDto) = Unit + + @RequiresEdt + protected open fun models(state: ModelStateDto) = Unit + + @RequiresEdt + protected open fun clearWorkspaceError() = Unit + + private companion object { + val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index 1f9a6e2b802..a48d410fbe1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -6,9 +6,8 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.model.ModelText -import ai.kilocode.client.settings.profile.edt import ai.kilocode.client.settings.base.BaseContentPanel -import ai.kilocode.client.settings.base.BaseSettingsUi +import ai.kilocode.client.settings.base.BaseWorkspaceSettingsUi import ai.kilocode.client.settings.base.SettingsBannerKind import ai.kilocode.client.settings.base.SettingsRow import ai.kilocode.client.settings.base.SettingsRows @@ -21,20 +20,25 @@ import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.ProvidersDto import com.intellij.openapi.components.service import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext internal class ModelsSettingsUi( - private val cs: CoroutineScope, + cs: CoroutineScope, private val app: KiloAppService = service(), private val workspaces: KiloWorkspaceService = service(), - private val directory: String? = null, -) : BaseSettingsUi(cs, ModelsDraft()) { + directory: String? = null, +) : BaseWorkspaceSettingsUi( + cs, + ModelsDraft(), + app, + workspaces, + directory, +) { companion object { private val LOG = KiloLog.create(ModelsSettingsUi::class.java) @@ -49,17 +53,11 @@ internal class ModelsSettingsUi( private var providers: ProvidersDto? = null private var agents: List = emptyList() - private var appState: KiloAppStateDto = app.state.value - private var dir: String? = null - private var loading = false - private var loaded = false private var errors: List = emptyList() private var allItems: List = emptyList() init { - setSettingsContent(ModelsSettingsContent(app, { updateDraft(it) }, ::selectSubagent)) - syncContent() - start() + startSettings(ModelsSettingsContent(app, { updateDraft(it) }, ::selectSubagent)) } override fun change(from: ModelsDraft, to: ModelsDraft): ConfigPatchDto? = patch(from, to).takeIf { @@ -70,10 +68,9 @@ internal class ModelsSettingsUi( app.updateConfigAsync(change, done) } - override fun base(result: KiloAppStateDto): ModelsDraft { - appState = result - return modelsDraft(result.config, agents) - } + override fun base(result: KiloAppStateDto): ModelsDraft = modelsDraft(result.config, agents) + + override fun draft(state: KiloAppStateDto): ModelsDraft = modelsDraft(state.config, agents) override fun saved(base: ModelsDraft, draft: ModelsDraft): Boolean = savedMatches(base, draft) @@ -91,82 +88,34 @@ internal class ModelsSettingsUi( override fun logSaveCompletedAfterDispose(change: ConfigPatchDto) = LOG.info("model settings save: completed after dispose ${summary(change)}") - @RequiresEdt - fun updateApp(state: KiloAppStateDto) { - appState = state - if (state.status != KiloAppStatusDto.READY) { - loading = false - if (!loaded && providers == null) { - agents = emptyList() - errors = emptyList() - } - syncContent() - return - } - val base = modelsDraft(state.config, agents) - acceptBase(base) - syncContent() - loadModels() - } - - @RequiresEdt - fun updateModelsWorkspace(state: ModelsWorkspaceDto) { - providers = state.providers - agents = state.agents?.agents ?: emptyList() - errors = state.errors - loaded = true - loading = false - val base = modelsDraft(appState.config, agents) - acceptBase(base) - syncContent() - } - - @RequiresEdt - fun updateModels(state: ai.kilocode.rpc.dto.ModelStateDto) { - syncContent() - } - - private fun start() { - jobs += cs.launch { - app.state.collect { state -> withContext(edt) { updateApp(state) } } - } - jobs += cs.launch { - app.models.collect { state -> withContext(edt) { updateModels(state) } } - } - jobs += cs.launch { app.connect() } - val hint = directory ?: return - jobs += cs.launch { - val resolved = workspaces.resolveProjectDirectory(hint) - withContext(edt) { - dir = resolved - loaded = false - loadModels() - syncContent() - } + override fun unavailable(state: KiloAppStateDto) { + if (!workspaceLoaded && providers == null) { + agents = emptyList() + errors = emptyList() } } - @RequiresEdt - private fun loadModels() { - val root = dir ?: return - if (appState.status != KiloAppStatusDto.READY || loading || loaded) return - loading = true + override fun models(state: ModelStateDto) = Unit + + override fun clearWorkspaceError() { errors = emptyList() - syncContent() - jobs += cs.launch { - val state = workspaces.models(root) - withContext(edt) { updateModelsWorkspace(state) } - } + } + + override suspend fun loadWorkspace(root: String): ModelsWorkspaceDto = workspaces.models(root) + + override fun applyWorkspace(result: ModelsWorkspaceDto) { + providers = result.providers + agents = result.agents?.agents ?: emptyList() + errors = result.errors } @RequiresEdt override fun syncContent() { allItems = items(false) val smallItems = items(true) - val hasDir = dir != null || directory != null val state = modelsStatus( - ready = appState.status == KiloAppStatusDto.READY && hasDir, - loading = loading || (appState.status == KiloAppStatusDto.READY && !loaded && hasDir), + ready = appState.status == KiloAppStatusDto.READY && hasProjectDirectory, + loading = workspaceLoading || (appState.status == KiloAppStatusDto.READY && !workspaceLoaded && hasProjectDirectory), providers = providers, items = allItems.size, errors = errors, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUiTest.kt new file mode 100644 index 00000000000..f588880117b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUiTest.kt @@ -0,0 +1,191 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ModelSelectionDto +import ai.kilocode.rpc.dto.ModelStateDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +class BaseWorkspaceSettingsUiTest : BasePlatformTestCase() { + private lateinit var appScope: CoroutineScope + private lateinit var uiScope: CoroutineScope + private lateinit var rpc: FakeAppRpcApi + private lateinit var workspaceRpc: FakeWorkspaceRpcApi + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private var panel: FakePanel? = null + + override fun setUp() { + super.setUp() + appScope = CoroutineScope(SupervisorJob()) + uiScope = CoroutineScope(SupervisorJob()) + rpc = FakeAppRpcApi() + workspaceRpc = FakeWorkspaceRpcApi() + app = KiloAppService(appScope, rpc) + workspaces = KiloWorkspaceService(appScope, workspaceRpc) + } + + override fun tearDown() { + try { + val view = panel + if (view != null) edt { view.dispose() } + panel = null + uiScope.cancel() + appScope.cancel() + } finally { + super.tearDown() + } + } + + fun `test startup accepts ready app state and loads resolved workspace`() { + rpc.state.value = state("new") + workspaceRpc.directory = "/resolved" + val view = create("/hint") + + flushUntil { edt { view.value() == "new" && view.roots == listOf("/resolved") } } + + edt { + assertEquals("new", view.value()) + assertEquals("/resolved", view.dir()) + assertTrue(view.loaded()) + assertFalse(view.loading()) + assertFalse(view.loadOnEdt) + } + } + + fun `test non ready app state calls unavailable hook`() { + rpc.state.value = state("ready") + val view = create("/test") + flushUntil { edt { view.value() == "ready" } } + val before = edt { view.unavailable } + + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) + flushUntil { edt { view.unavailable > before } } + + edt { assertFalse(view.loading()) } + } + + fun `test model state updates are delivered on edt`() { + rpc.models = ModelStateDto(favorite = listOf(ModelSelectionDto("kilo", "new"))) + rpc.state.value = state("ready") + val view = create("/test") + + flushUntil { edt { view.favoriteCount == 1 } } + + edt { + assertEquals(1, view.favoriteCount) + assertTrue(view.modelsOnEdt) + } + } + + private fun create(hint: String): FakePanel { + val view = edt { FakePanel(uiScope, app, workspaces, hint) } + panel = view + return view + } + + private fun state(model: String) = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = model), + ) + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(20) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } + + private data class Draft(val value: String) + private data class Change(val value: String) + + private class FakeContent : BaseContentPanel() + + private class FakePanel( + cs: CoroutineScope, + app: KiloAppService, + workspaces: KiloWorkspaceService, + hint: String, + ) : BaseWorkspaceSettingsUi( + cs, + Draft("old"), + app, + workspaces, + hint, + ) { + val roots = mutableListOf() + var unavailable = 0 + private set + var favoriteCount = 0 + private set + var loadOnEdt = true + private set + var modelsOnEdt = false + private set + + init { + startSettings(FakeContent()) + } + + fun value(): String = draft.value + + fun dir(): String? = projectDirectory + + fun loading(): Boolean = workspaceLoading + + fun loaded(): Boolean = workspaceLoaded + + override fun change(from: Draft, to: Draft): Change? = if (from == to) null else Change(to.value) + + override fun save(change: Change, done: (Draft?) -> Unit) = done(Draft(change.value)) + + override fun base(result: Draft): Draft = result + + override fun draft(state: KiloAppStateDto): Draft = Draft(state.config?.model ?: "none") + + override suspend fun loadWorkspace(root: String): String { + loadOnEdt = ApplicationManager.getApplication().isDispatchThread + roots += root + return root + } + + override fun applyWorkspace(result: String) = Unit + + override fun unavailable(state: KiloAppStateDto) { + unavailable++ + } + + override fun models(state: ModelStateDto) { + favoriteCount = state.favorite.size + modelsOnEdt = ApplicationManager.getApplication().isDispatchThread + } + + override fun syncContent() = Unit + + override fun pendingText(): String = "Saving" + + override fun failedText(): String = "Failed" + } +} From ebeb65ad77ce93008008e1de11f5e1267dd42dfc Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 17:26:49 -0400 Subject: [PATCH 072/201] refactor(kilo-jetbrains): unify settings base lifecycle --- .../client/settings/base/BaseSettingsUi.kt | 115 +++++++++++++++- .../settings/base/BaseWorkspaceSettingsUi.kt | 127 ------------------ .../settings/models/ModelsSettingsUi.kt | 4 +- .../settings/base/BaseSettingsUiTest.kt | 27 +++- ...Test.kt => BaseSettingsUiWorkspaceTest.kt} | 6 +- 5 files changed, 141 insertions(+), 138 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUi.kt rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/{BaseWorkspaceSettingsUiTest.kt => BaseSettingsUiWorkspaceTest.kt} (97%) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt index ccafc479990..727cf0b0e85 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseSettingsUi.kt @@ -1,25 +1,40 @@ package ai.kilocode.client.settings.base import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ModelStateDto import com.intellij.ide.DataManager import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.options.ex.Settings import com.intellij.openapi.project.ProjectManager +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.function.Predicate import javax.swing.JComponent -internal abstract class BaseSettingsUi( +internal abstract class BaseSettingsUi( protected val scope: CoroutineScope, initial: D, + private val app: KiloAppService = service(), + private val workspaces: KiloWorkspaceService = service(), + private val hint: String? = null, private val loginBanner: Boolean = true, ) : SettingsPanel() { protected lateinit var form: C @@ -28,6 +43,17 @@ internal abstract class BaseSettingsUi( protected var draft = initial protected val saving get() = save protected val saveError get() = error + protected var appState: KiloAppStateDto = app.state.value + private set + protected var modelState: ModelStateDto = app.models.value + private set + protected var projectDirectory: String? = null + private set + protected val hasProjectDirectory get() = projectDirectory != null || hint != null + protected var workspaceLoading = false + private set + protected var workspaceLoaded = false + private set private var baseline = initial private var pending: D? = null @@ -35,9 +61,72 @@ internal abstract class BaseSettingsUi( private var error: String? = null private var disposed = false - protected fun setSettingsContent(content: C) { + @RequiresEdt + protected fun startSettings(content: C) { form = content setContent(content) + syncContent() + start() + } + + private fun start() { + jobs += scope.launch { + app.state.collect { state -> withContext(edt) { updateApp(state) } } + } + jobs += scope.launch { + app.models.collect { state -> withContext(edt) { updateModels(state) } } + } + jobs += scope.launch { app.connect() } + val path = hint ?: return + jobs += scope.launch { + val dir = workspaces.resolveProjectDirectory(path) + withContext(edt) { + projectDirectory = dir + workspaceLoaded = false + syncContent() + load() + } + } + } + + @RequiresEdt + private fun updateApp(state: KiloAppStateDto) { + appState = state + if (state.status != KiloAppStatusDto.READY) { + workspaceLoading = false + unavailable(state) + syncContent() + return + } + acceptBase(draft(state)) + syncContent() + load() + } + + @RequiresEdt + private fun updateModels(state: ModelStateDto) { + modelState = state + models(state) + syncContent() + } + + @RequiresEdt + private fun load() { + val root = projectDirectory ?: return + if (appState.status != KiloAppStatusDto.READY || workspaceLoading || workspaceLoaded) return + workspaceLoading = true + clearWorkspaceError() + syncContent() + jobs += scope.launch { + val state = loadWorkspace(root) + withContext(edt) { + applyWorkspace(state) + workspaceLoaded = true + workspaceLoading = false + acceptBase(draft(appState)) + syncContent() + } + } } @RequiresEdt @@ -167,6 +256,15 @@ internal abstract class BaseSettingsUi( @RequiresEdt protected abstract fun failedText(): String + @RequiresEdt + protected abstract fun draft(state: KiloAppStateDto): D + + @RequiresBackgroundThread + protected abstract suspend fun loadWorkspace(root: String): W + + @RequiresEdt + protected abstract fun applyWorkspace(result: W) + @RequiresEdt protected open fun saved(base: D, draft: D): Boolean = base == draft @@ -188,6 +286,15 @@ internal abstract class BaseSettingsUi( @RequiresEdt protected open fun logSaveCompletedAfterDispose(change: P) = Unit + @RequiresEdt + protected open fun unavailable(state: KiloAppStateDto) = Unit + + @RequiresEdt + protected open fun models(state: ModelStateDto) = Unit + + @RequiresEdt + protected open fun clearWorkspaceError() = Unit + private fun openProfile(src: JComponent) { val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) if (settings != null) { @@ -207,4 +314,8 @@ internal abstract class BaseSettingsUi( { cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) }, ) } + + private companion object { + val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUi.kt deleted file mode 100644 index 17c1c4f92ee..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUi.kt +++ /dev/null @@ -1,127 +0,0 @@ -package ai.kilocode.client.settings.base - -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.app.KiloWorkspaceService -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.ModelStateDto -import com.intellij.openapi.application.EDT -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.asContextElement -import com.intellij.openapi.components.service -import com.intellij.util.concurrency.annotations.RequiresBackgroundThread -import com.intellij.util.concurrency.annotations.RequiresEdt -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -internal abstract class BaseWorkspaceSettingsUi( - cs: CoroutineScope, - initial: D, - private val app: KiloAppService = service(), - private val workspaces: KiloWorkspaceService = service(), - private val hint: String? = null, - loginBanner: Boolean = true, -) : BaseSettingsUi(cs, initial, loginBanner) { - protected var appState: KiloAppStateDto = app.state.value - private set - protected var modelState: ModelStateDto = app.models.value - private set - protected var projectDirectory: String? = null - private set - protected val hasProjectDirectory get() = projectDirectory != null || hint != null - protected var workspaceLoading = false - private set - protected var workspaceLoaded = false - private set - - @RequiresEdt - protected fun startSettings(content: C) { - setSettingsContent(content) - syncContent() - start() - } - - private fun start() { - jobs += scope.launch { - app.state.collect { state -> withContext(edt) { updateApp(state) } } - } - jobs += scope.launch { - app.models.collect { state -> withContext(edt) { updateModels(state) } } - } - jobs += scope.launch { app.connect() } - val path = hint ?: return - jobs += scope.launch { - val dir = workspaces.resolveProjectDirectory(path) - withContext(edt) { - projectDirectory = dir - workspaceLoaded = false - syncContent() - load() - } - } - } - - @RequiresEdt - private fun updateApp(state: KiloAppStateDto) { - appState = state - if (state.status != KiloAppStatusDto.READY) { - workspaceLoading = false - unavailable(state) - syncContent() - return - } - acceptBase(draft(state)) - syncContent() - load() - } - - @RequiresEdt - private fun updateModels(state: ModelStateDto) { - modelState = state - models(state) - syncContent() - } - - @RequiresEdt - private fun load() { - val root = projectDirectory ?: return - if (appState.status != KiloAppStatusDto.READY || workspaceLoading || workspaceLoaded) return - workspaceLoading = true - clearWorkspaceError() - syncContent() - jobs += scope.launch { - val state = loadWorkspace(root) - withContext(edt) { - applyWorkspace(state) - workspaceLoaded = true - workspaceLoading = false - acceptBase(draft(appState)) - syncContent() - } - } - } - - @RequiresEdt - protected abstract fun draft(state: KiloAppStateDto): D - - @RequiresBackgroundThread - protected abstract suspend fun loadWorkspace(root: String): W - - @RequiresEdt - protected abstract fun applyWorkspace(result: W) - - @RequiresEdt - protected open fun unavailable(state: KiloAppStateDto) = Unit - - @RequiresEdt - protected open fun models(state: ModelStateDto) = Unit - - @RequiresEdt - protected open fun clearWorkspaceError() = Unit - - private companion object { - val edt = Dispatchers.EDT + ModalityState.any().asContextElement() - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index a48d410fbe1..322e5fcfcbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -7,7 +7,7 @@ import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.model.ModelText import ai.kilocode.client.settings.base.BaseContentPanel -import ai.kilocode.client.settings.base.BaseWorkspaceSettingsUi +import ai.kilocode.client.settings.base.BaseSettingsUi import ai.kilocode.client.settings.base.SettingsBannerKind import ai.kilocode.client.settings.base.SettingsRow import ai.kilocode.client.settings.base.SettingsRows @@ -32,7 +32,7 @@ internal class ModelsSettingsUi( private val app: KiloAppService = service(), private val workspaces: KiloWorkspaceService = service(), directory: String? = null, -) : BaseWorkspaceSettingsUi( +) : BaseSettingsUi( cs, ModelsDraft(), app, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt index 88a4f265394..4966673eb57 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiTest.kt @@ -1,5 +1,10 @@ package ai.kilocode.client.settings.base +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto import com.intellij.openapi.application.ApplicationManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil @@ -14,11 +19,17 @@ import javax.swing.text.JTextComponent class BaseSettingsUiTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope + private lateinit var appScope: CoroutineScope + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService private var panel: FakePanel? = null override fun setUp() { super.setUp() scope = CoroutineScope(SupervisorJob()) + appScope = CoroutineScope(SupervisorJob()) + app = KiloAppService(appScope, FakeAppRpcApi()) + workspaces = KiloWorkspaceService(appScope, FakeWorkspaceRpcApi()) } override fun tearDown() { @@ -27,6 +38,7 @@ class BaseSettingsUiTest : BasePlatformTestCase() { if (view != null) edt { view.dispose() } panel = null scope.cancel() + appScope.cancel() } finally { super.tearDown() } @@ -137,7 +149,7 @@ class BaseSettingsUiTest : BasePlatformTestCase() { } private fun create(login: Boolean = true): FakePanel { - val view = edt { FakePanel(scope, login) } + val view = edt { FakePanel(scope, app, workspaces, login) } panel = view return view } @@ -181,15 +193,16 @@ class BaseSettingsUiTest : BasePlatformTestCase() { private class FakePanel( cs: CoroutineScope, + app: KiloAppService, + workspaces: KiloWorkspaceService, login: Boolean, - ) : BaseSettingsUi(cs, Draft("old"), login) { + ) : BaseSettingsUi(cs, Draft("old"), app, workspaces, loginBanner = login) { private val callbacks = mutableListOf<(Draft?) -> Unit>() var disposedFailures = 0 private set init { - setSettingsContent(FakeContent()) - syncContent() + startSettings(FakeContent()) } fun edit(value: String) = updateDraft { copy(value = value) } @@ -210,6 +223,12 @@ class BaseSettingsUiTest : BasePlatformTestCase() { override fun base(result: Draft): Draft = result + override fun draft(state: KiloAppStateDto): Draft = draft + + override suspend fun loadWorkspace(root: String) = Unit + + override fun applyWorkspace(result: Unit) = Unit + override fun syncContent() { val err = saveError if (saving) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiWorkspaceTest.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUiTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiWorkspaceTest.kt index f588880117b..c1a7070266a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseWorkspaceSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/BaseSettingsUiWorkspaceTest.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -class BaseWorkspaceSettingsUiTest : BasePlatformTestCase() { +class BaseSettingsUiWorkspaceTest : BasePlatformTestCase() { private lateinit var appScope: CoroutineScope private lateinit var uiScope: CoroutineScope private lateinit var rpc: FakeAppRpcApi @@ -54,7 +54,7 @@ class BaseWorkspaceSettingsUiTest : BasePlatformTestCase() { workspaceRpc.directory = "/resolved" val view = create("/hint") - flushUntil { edt { view.value() == "new" && view.roots == listOf("/resolved") } } + flushUntil { edt { view.value() == "new" && view.roots == listOf("/resolved") && view.loaded() } } edt { assertEquals("new", view.value()) @@ -128,7 +128,7 @@ class BaseWorkspaceSettingsUiTest : BasePlatformTestCase() { app: KiloAppService, workspaces: KiloWorkspaceService, hint: String, - ) : BaseWorkspaceSettingsUi( + ) : BaseSettingsUi( cs, Draft("old"), app, From 56b0834db524d29737043c250b45e8c973add350 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 18:03:24 -0400 Subject: [PATCH 073/201] fix(jetbrains): dispose hidden session UIs after timeout --- .changeset/hidden-session-timers.md | 5 + .../client/session/SessionSidePanelManager.kt | 32 ++++-- .../ai/kilocode/client/session/SessionUi.kt | 3 - .../session/controller/SessionController.kt | 1 + .../session/controller/SessionUpdateQueue.kt | 28 +++--- .../resources/kilo.jetbrains.frontend.xml | 6 +- .../session/SessionSidePanelManagerTest.kt | 72 +++++++------- specs/jetbrains-session-ui-findings-todo.md | 97 +++++++++++++++++++ 8 files changed, 180 insertions(+), 64 deletions(-) create mode 100644 .changeset/hidden-session-timers.md create mode 100644 specs/jetbrains-session-ui-findings-todo.md diff --git a/.changeset/hidden-session-timers.md b/.changeset/hidden-session-timers.md new file mode 100644 index 00000000000..f71146c1cd2 --- /dev/null +++ b/.changeset/hidden-session-timers.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce JetBrains memory usage by disposing hidden session UIs after a configurable timeout. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 2a1a7e1f10d..c887afd8874 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.cancel import java.awt.BorderLayout import javax.swing.JComponent import javax.swing.JPanel +import javax.swing.Timer class SessionSidePanelManager( private val project: Project, @@ -40,6 +41,7 @@ class SessionSidePanelManager( private val opened = mutableMapOf() private val all = mutableSetOf() + private val timers = mutableMapOf() private var current: SessionUi? = null private var latest: SessionUi? = null private var panel: JComponent? = null @@ -163,6 +165,7 @@ class SessionSidePanelManager( } private fun show(ui: SessionUi) { + cancel(ui) all.add(ui) register(ui) latest = ui @@ -186,18 +189,12 @@ class SessionSidePanelManager( disposeUi(ui) return } - if (!disposeInactiveUi()) { - register(ui) - return - } - if (ui.canDisposeInactive()) { - disposeUi(ui) - return - } register(ui) + schedule(ui) } private fun disposeUi(ui: SessionUi) { + cancel(ui) opened.entries.removeIf { it.value === ui } all.remove(ui) if (current === ui) current = null @@ -205,10 +202,27 @@ class SessionSidePanelManager( Disposer.dispose(ui) } - private fun disposeInactiveUi() = Registry.`is`("kilo.session.inactive.dispose", false) + private fun schedule(ui: SessionUi) { + cancel(ui) + val delay = Registry.intValue("kilo.session.inactive.disposeTimeoutMs").coerceAtLeast(0) + val timer = Timer(delay) { + timers.remove(ui) + if (ui === current || ui !in all) return@Timer + disposeUi(ui) + } + timer.isRepeats = false + timers[ui] = timer + timer.start() + } + + private fun cancel(ui: SessionUi) { + timers.remove(ui)?.stop() + } override fun dispose() { val items = all.toList() + timers.values.forEach { it.stop() } + timers.clear() opened.clear() all.clear() current = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 2a6abcea561..6f27970002e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -198,9 +198,6 @@ class SessionUi( sink[PlatformDataKeys.COPY_PROVIDER] = copy } - @RequiresEdt - internal fun canDisposeInactive(): Boolean = controller.model.state is SessionState.Idle - @RequiresEdt internal fun activityKind(): SessionActivityKind? = when (val state = controller.model.state) { is SessionState.Idle, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index a1c2076218e..0075a889582 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -113,6 +113,7 @@ class SessionController( private val directory: String get() = workspace.directory private val updates = SessionUpdateQueue( parent, + cs, comp, flushMs, ::handle, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt index c5b63c2f2a2..e64a83c96f7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt @@ -9,15 +9,18 @@ import com.intellij.openapi.util.Disposer import java.awt.Component import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch internal const val EVENT_FLUSH_MS = 150L internal class SessionUpdateQueue( parent: Disposable, + cs: CoroutineScope, private val comp: Component?, private val flushMs: Long = EVENT_FLUSH_MS, private val fire: (List) -> Unit, @@ -34,8 +37,14 @@ internal class SessionUpdateQueue( private val condenser = SessionQueueCondenser() private val pending = mutableListOf() private val lock = Any() - private val exec: ScheduledExecutorService? = if (flushMs == Long.MAX_VALUE) null else Executors.newSingleThreadScheduledExecutor() private val visible = AtomicBoolean(comp?.isShowing ?: true) + private val tick: Job? = if (flushMs == Long.MAX_VALUE) null else cs.launch { + while (isActive) { + delay(flushMs) + if (!visible.get()) continue + requestFlush(false, "tick") + } + } private val watch = comp?.let { HierarchyListener { event -> if (event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@HierarchyListener @@ -48,15 +57,6 @@ internal class SessionUpdateQueue( init { Disposer.register(parent, this) if (comp != null && watch != null) comp.addHierarchyListener(watch) - exec?.scheduleAtFixedRate( - { - if (!visible.get()) return@scheduleAtFixedRate - requestFlush(false, "tick") - }, - flushMs, - flushMs, - TimeUnit.MILLISECONDS, - ) } fun enqueue(event: ChatEventDto) { @@ -88,7 +88,7 @@ internal class SessionUpdateQueue( override fun dispose() { val size = synchronized(lock) { pending.size } LOG.debug { "${ChatLogSummary.sid(sid())} dispose pending=$size" } - exec?.shutdownNow() + tick?.cancel() if (comp != null && watch != null) comp.removeHierarchyListener(watch) if (app.isDispatchThread) { synchronized(lock) { pending.clear() } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index e801b3b2c1a..79986560a65 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -39,9 +39,9 @@ defaultValue="150" restartRequired="false" overrides="false"/> - diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 154fa2f0eb6..b3013d9b21f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -135,7 +135,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same existing session reuses component`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() val manager = manager() val session = session("ses_1") @@ -150,7 +150,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test prompted blank session is reused from recents`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() val manager = manager() manager.newSession() val first = active(manager) @@ -197,7 +197,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test inactive sessions keep queued style updates`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) val first = active(manager) as SessionUi @@ -211,35 +211,35 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertSame(style, first.currentStyle()) } - fun `test dispose inactive flag disposes previous session ui`() { - enableDisposeInactiveUi() + fun `test hidden session is disposed after timeout`() { + useShortInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) val first = active(manager) - settle() manager.openSession(session("ses_2")) + settle() assertFalse(ui.contains(first)) assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2"), created) } - fun `test dispose inactive flag recreates reopened session ui`() { - enableDisposeInactiveUi() + fun `test reopening after hidden timeout recreates session ui`() { + useShortInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) val first = active(manager) - settle() manager.openSession(session("ses_2")) + settle() manager.openSession(session("ses_1")) assertNotSame(first, active(manager)) assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2", "/test" to "ses_1"), created) } - fun `test dispose inactive flag keeps pending session for history overlays`() { - enableDisposeInactiveUi() + fun `test pending session is retained for history overlays before timeout`() { + useLongInactiveDisposeTimeout() val history = JLabel("History") val manager = manager(history = { _, _, _ -> history }) @@ -254,8 +254,8 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertTrue(ui.contains(first)) } - fun `test history overlays update with inactive dispose enabled`() { - enableDisposeInactiveUi() + fun `test history overlays update before hidden timeout`() { + useLongInactiveDisposeTimeout() lateinit var history: HistoryPanel val manager = manager(history = { parent, _, _ -> val controller = HistoryController(sessions, workspace, scope) @@ -287,7 +287,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test history overlays update from hidden session stream metadata`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() lateinit var history: HistoryPanel val manager = manager(history = { parent, _, _ -> val controller = HistoryController(sessions, workspace, scope) @@ -342,7 +342,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test history back restores latest open session`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) @@ -415,7 +415,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same cloud session while in-flight reuses existing ui`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() rpc.historyGate = kotlinx.coroutines.CompletableDeferred() rpc.importedCloudSession = session("ses_imported") val manager = manager() @@ -435,7 +435,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test imported cloud session is reused when opened as local`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() rpc.importedCloudSession = session("ses_imported") val manager = manager() @@ -461,7 +461,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same local session while in-flight reuses existing ui`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() val gate = kotlinx.coroutines.CompletableDeferred() rpc.historyGate = gate val manager = manager() @@ -531,7 +531,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test activity reports plan and question separately`() { - disableInactiveDispose() + useLongInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_plan")) val plan = active(manager) @@ -553,8 +553,8 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { ) } - fun `test inactive dispose keeps permission session ui`() { - enableDisposeInactiveUi() + fun `test hidden permission session ui is disposed after timeout`() { + useShortInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) val first = active(manager) @@ -562,13 +562,14 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { first.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1"))) } manager.openSession(session("ses_2")) + settle() - assertTrue(ui.contains(first)) - assertEquals(mapOf("ses_1" to SessionActivityKind.PERMISSION), manager.activity()) + assertFalse(ui.contains(first)) + assertEquals(emptyMap(), manager.activity()) } - fun `test inactive dispose keeps busy session ui`() { - enableDisposeInactiveUi() + fun `test hidden busy session ui is disposed after timeout`() { + useShortInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) val first = active(manager) @@ -576,17 +577,18 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { first.controller().model.setState(SessionState.Busy("running")) } manager.openSession(session("ses_2")) + settle() - assertTrue(ui.contains(first)) + assertFalse(ui.contains(first)) } fun `test activity ignores disposed idle session ui`() { - enableDisposeInactiveUi() + useShortInactiveDisposeTimeout() val manager = manager() manager.openSession(session("ses_1")) val first = active(manager) - settle() manager.openSession(session("ses_2")) + settle() assertFalse(ui.contains(first)) assertEquals(emptyMap(), manager.activity()) @@ -621,17 +623,17 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel - private fun enableDisposeInactiveUi() = setInactiveDispose(true) + private fun useShortInactiveDisposeTimeout() = setInactiveDisposeTimeout(10) - private fun disableInactiveDispose() = setInactiveDispose(false) + private fun useLongInactiveDisposeTimeout() = setInactiveDisposeTimeout(60_000) - private fun setInactiveDispose(enabled: Boolean) { - val key = "kilo.session.inactive.dispose" + private fun setInactiveDisposeTimeout(ms: Int) { + val key = "kilo.session.inactive.disposeTimeoutMs" Registry.mutateContributedKeys { it + (key to RegistryKeyDescriptor( key, - "Dispose inactive session UI when switching sessions instead of retaining it in memory.", - "false", + "Milliseconds before hidden session UI is disposed after switching away.", + "180000", false, false, null, @@ -641,7 +643,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { Disposer.register(testRootDisposable) { Registry.mutateContributedKeys { it - key } } - Registry.get(key).setValue(enabled, testRootDisposable) + Registry.get(key).setValue(ms, testRootDisposable) } private fun JPanel.controller(): ai.kilocode.client.session.controller.SessionController { diff --git a/specs/jetbrains-session-ui-findings-todo.md b/specs/jetbrains-session-ui-findings-todo.md new file mode 100644 index 00000000000..c72e95cc9b1 --- /dev/null +++ b/specs/jetbrains-session-ui-findings-todo.md @@ -0,0 +1,97 @@ +# JetBrains Session UI Findings Todo + +Use this as a planning backlog for the JetBrains session UI performance, memory, thread-safety, and AGENTS.md conformance review. + +Last status check: 2026-06-02. + +## High Priority + +- [x] Fix streaming markdown full-tree rebuilds + - Severity: High + - Status: Addressed for the high-priority full-tree rebuild issue. Remaining repaint/revalidate cleanup is tracked separately under medium priority. + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt` + - Issue: `ContentDelta` updates fetch full content and route through `TextView.update()`/`md.set()`, causing markdown to reparse and recreate all child components, including code editors, on each streaming chunk. + - Plan direction: Restore incremental delta handling where safe, avoid full `set()` for append-only text, and retain/reuse markdown/code block components where possible. + - Current evidence: `ContentDelta` carries `created`, `SessionMessageListPanel` routes normal deltas through `appendDelta()`, and `MdViewHybrid` retains compatible HTML/code block views instead of clearing all rendered blocks. + +- [x] Prevent hidden cached sessions from accumulating resources + - Severity: High + - Status: Addressed with timed hidden-session disposal and shared coroutine-based queue ticking. + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt` + - Issue: Inactive cached session UIs keep controller subscriptions, scheduler threads, models, Swing trees, and pending event queues alive. Non-metadata events can accumulate while hidden. + - Plan direction: Bound hidden transcript event queues with unconditional timed disposal, and avoid one scheduler thread per session UI. + - Current evidence: `kilo.session.inactive.dispose` was removed, `kilo.session.inactive.disposeTimeoutMs` now defaults to 180000 ms, hidden cached `SessionUi` instances are disposed by an EDT timer after the timeout unless shown again, and `SessionUpdateQueue` now uses the controller coroutine scope for its ticker instead of creating a per-queue scheduler thread. + +- [ ] Confine controller subscription mutation to EDT + - Severity: High + - Status: Not addressed; still needs work. + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt` + - Issue: `prompt()` calls `subscribeEvents()` from a background coroutine after session creation; `subscribeEvents()` mutates `eventJob`, `childJobs`, and `childIds` without EDT confinement. + - Plan direction: Run subscription-state mutation on EDT or separate thread-safe subscription state from EDT-only controller state. + - Current evidence: `prompt()` still calls `subscribeEvents()` from inside `cs.launch`, and `subscribeEvents()` still mutates `eventJob`, `childJobs`, and `childIds` without an EDT assertion or EDT handoff. + +## Medium Priority + +- [ ] Remove repaint/revalidate cascades on streaming deltas + - Severity: Medium + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt` + - Issue: A single content delta can call `refresh()` at child, message, and transcript levels. + - Plan direction: Let changed child views invalidate themselves only when size/paint changes; avoid parent refresh for delegated content updates. + +- [ ] Lazy-create collapsed tool and reasoning bodies + - Severity: Medium + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt` + - Issue: Collapsed and even non-expandable views eagerly create `JBTextArea`, `JBScrollPane`, and markdown bodies. + - Plan direction: Build only headers initially; create body components on first expansion or first direct access; avoid unused body for non-expandable read views. + +- [ ] Explicitly dispose transient question custom editors + - Severity: Medium + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt` + - Issue: `syncPage()` and `hideView()` remove/null `SessionEditorTextField` instances without explicit disposal of editor/listener resources. + - Plan direction: Track editor disposables, call the appropriate `EditorTextField` disposal mechanism, and test repeated custom-row toggle/navigation. + +- [ ] Add EDT annotations/assertions to session model and UI paths + - Severity: Medium + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt` + - Issue: `SessionModel` is documented EDT-only, but public APIs and several Swing-mutating methods lack `@RequiresEdt` or assertions. + - Plan direction: Annotate EDT-only methods, add runtime assertions where useful, and ensure listener callbacks remain EDT-only. + +- [ ] Guard queued style callbacks after disposal + - Severity: Medium + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt` + - Issue: theme/editor color listeners schedule `invokeLater { applyStyle(...) }` without checking `disposed` before mutating components. + - Plan direction: Check `disposed` in queued callbacks and at the start of `applyStyle()`. + +- [ ] Confine `SessionUpdateQueue` Swing listener operations to EDT + - Severity: Medium + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt` + - Issue: constructor reads `comp.isShowing` and adds a hierarchy listener without an EDT contract; `dispose()` removes the listener directly. + - Plan direction: Add `@RequiresEdt`/assertions for construction and disposal or marshal Swing listener operations to EDT. + +- [ ] Reduce full body rebuilds in question UI + - Severity: Medium + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt` + - Issue: question navigation/toggle paths call `body.removeAll()` and recreate rows/buttons/listeners. + - Plan direction: Retain per-page controls where practical, update existing button/text state, and avoid rebuilding action buttons for simple label/enabled changes. + +## Low Priority + +- [ ] Replace hardcoded timeline runtime colors with semantic named colors + - Severity: Low/Medium + - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt` + - Issue: timeline color tokens use numeric `JBColor(Color(...), Color(...))`; AGENTS.md prefers theme-derived or `JBColor.namedColor(...)` semantic keys. + - Plan direction: Introduce named color keys with appropriate fallbacks or map to existing platform theme colors. + +- [ ] Add focused regression tests for retained Swing behavior + - Severity: Low + - Files: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/**` + - Issue: Several risks need tests: no parent refresh on no-op deltas, lazy body creation, editor disposal, hidden-session queue bounds, and EDT assertions. + - Plan direction: Extend existing session UI/controller tests using real IntelliJ EDT fixtures, not mocks. + +## Confirmed OK + +- No JCEF usage found in inspected session UI paths. +- No Compose usage found in inspected session UI paths. +- No Kotlin UI DSL usage found in inspected session UI paths. +- Session views generally route user actions through `SessionController` rather than direct RPC. +- Controller RPC calls generally run through coroutines rather than blocking EDT. From 7e856a57c4fd3dc29effb64be41d9dbd7554c346 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 18:05:17 -0400 Subject: [PATCH 074/201] fix(kilo-jetbrains): polish profile and picker UI --- .changeset/crisp-cactus-jetbrains-settings.md | 5 ++ .../client/session/ui/ReasoningPicker.kt | 9 ++- .../client/session/ui/model/ModelPicker.kt | 5 +- .../settings/profile/LoggedInProfileUi.kt | 62 +++++++++---------- .../settings/profile/LoggedOutProfileUi.kt | 12 +++- .../src/main/resources/icons/kilo-profile.svg | 4 ++ .../resources/icons/kilo-profile_dark.svg | 4 ++ .../resources/kilo.jetbrains.frontend.xml | 2 +- .../client/actions/KiloRecoveryActionsTest.kt | 8 +++ .../client/session/ui/PromptPanelTest.kt | 1 + .../session/ui/model/ModelPickerTest.kt | 3 + .../settings/UserProfileConfigurableTest.kt | 30 +++++++++ 12 files changed, 101 insertions(+), 44 deletions(-) create mode 100644 .changeset/crisp-cactus-jetbrains-settings.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile_dark.svg diff --git a/.changeset/crisp-cactus-jetbrains-settings.md b/.changeset/crisp-cactus-jetbrains-settings.md new file mode 100644 index 00000000000..c591667b1e1 --- /dev/null +++ b/.changeset/crisp-cactus-jetbrains-settings.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains model settings dropdown direction, logged-out profile branding, and CLI menu labeling. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt index 60b925da60a..7d50410baeb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt @@ -5,7 +5,6 @@ import ai.kilocode.client.ui.PickerButton import com.intellij.icons.AllIcons import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.ui.popup.ListPopup -import com.intellij.openapi.ui.popup.PopupShowOptions import com.intellij.openapi.ui.popup.PopupStep import com.intellij.openapi.ui.popup.util.BaseListPopupStep import com.intellij.util.ui.EmptyIcon @@ -17,8 +16,8 @@ import javax.swing.Icon /** * Clickable label-style dropdown picker with a native filled background. * - * Shows the selected item's display text with an up-arrow. On click, - * opens a list popup above the picker. Disabled (greyed out, not + * Shows the selected item's display text with a down-arrow. On click, + * opens a list popup below the picker. Disabled (greyed out, not * clickable) when no items are loaded. */ class ReasoningPicker : PickerButton() { @@ -76,7 +75,7 @@ class ReasoningPicker : PickerButton() { } isVisible = true val display = selected?.display ?: items.firstOrNull()?.display ?: "" - text = "$display ▴" + text = "$display ▾" isEnabled = true cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } @@ -96,7 +95,7 @@ class ReasoningPicker : PickerButton() { } val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step) - popup.show(PopupShowOptions.aboveComponent(this)) + popup.showUnderneathOf(this) } private fun icon(item: Item): Icon = if (item.id == selected?.id) checked else empty diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index 7ab82397889..00cc72ec6b9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -5,7 +5,6 @@ import ai.kilocode.client.ui.PickerButton import ai.kilocode.rpc.dto.ModelSelectionDto import com.intellij.openapi.ui.popup.JBPopup import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.ui.popup.PopupShowOptions import com.intellij.openapi.ui.popup.util.PopupUtil import com.intellij.ui.CollectionListModel import com.intellij.ui.DocumentAdapter @@ -115,7 +114,7 @@ class ModelPicker : PickerButton() { return } val display = selected?.display - text = if (display == null && allowEmpty) "$emptyText ▴" else "${ModelText.sanitize(display ?: items.first().display)} ▴" + text = if (display == null && allowEmpty) "$emptyText ▾" else "${ModelText.sanitize(display ?: items.first().display)} ▾" isEnabled = true cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } @@ -297,7 +296,7 @@ class ModelPicker : PickerButton() { .setMovable(false) .createPopup() - popup.show(PopupShowOptions.aboveComponent(this)) + popup.showUnderneathOf(this) SwingUtilities.invokeLater { search.textEditor.requestFocusInWindow() search.selectText() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt index aa74f1c5d31..c3019b12721 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt @@ -3,17 +3,20 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle +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.log.KiloLog import ai.kilocode.rpc.dto.ProfileDto import com.intellij.icons.AllIcons import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.util.IconLoader import com.intellij.ui.RelativeFont import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel -import java.awt.GridBagConstraints -import java.awt.GridBagLayout import java.awt.KeyboardFocusManager import java.awt.event.FocusEvent import java.awt.event.FocusListener @@ -22,6 +25,7 @@ import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingConstants +import java.awt.BorderLayout /** * Retained logged-in UI. Labels, combo box, and buttons are built once and @@ -43,6 +47,10 @@ internal class LoggedInProfileUi( foreground = UiStyle.Colors.weak() setCopyable(true) } + private val logoLabel = JBLabel(IconLoader.getIcon("/icons/kilo-profile.svg", LoggedInProfileUi::class.java)).apply { + name = "kilo.profile.logo.loggedIn" + accessibleContext.accessibleName = KiloBundle.message("settings.kilo.displayName") + } private val titleLabel = JBLabel(KiloBundle.message("profile.balance.title")).apply { foreground = UiStyle.Colors.weak() @@ -65,16 +73,10 @@ internal class LoggedInProfileUi( private val balanceCard = RoundedContentPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply { name = "kilo.profile.balanceCard" addToTop(titleLabel) - addToCenter(JPanel(GridBagLayout()).apply { - isOpaque = false - add(valueLabel, GridBagConstraints().apply { - gridx = 0; gridy = 0; anchor = GridBagConstraints.CENTER - }) - add(refreshBtn, GridBagConstraints().apply { - gridx = 0; gridy = 1; anchor = GridBagConstraints.CENTER - insets = JBUI.insetsTop(UiStyle.Gap.pad()) - }) - }) + addToCenter(Stack.vertical(UiStyle.Gap.pad()) + .next(valueLabel) + .next(refreshBtn) + .align(HAlign.CENTER, VAlign.CENTER)) } private val comboModel = DefaultComboBoxModel() @@ -85,29 +87,23 @@ internal class LoggedInProfileUi( val logoutBtn = JButton(KiloBundle.message("profile.action.logout")) .also { it.addActionListener { logout() } } - private val actionRow = JPanel(GridBagLayout()).apply { - add(dashboardBtn, GridBagConstraints().apply { - gridx = 0; gridy = 0; anchor = GridBagConstraints.WEST - }) - add(logoutBtn, GridBagConstraints().apply { - gridx = 1; gridy = 0; anchor = GridBagConstraints.WEST - insets = JBUI.insetsLeft(UiStyle.Gap.md()) - }) + private val actionRow = Stack.horizontal(UiStyle.Gap.md()) + .next(dashboardBtn) + .next(logoutBtn) + + private val header = JPanel(BorderLayout()).apply { + isOpaque = false + add(Stack.vertical(UiStyle.Gap.lg()) + .next(nameLabel) + .next(emailLabel), BorderLayout.CENTER) + add(logoLabel, BorderLayout.EAST) } - private val rows: List = listOf(nameLabel, emailLabel, combo, balanceCard, actionRow) - - private val content = JPanel(GridBagLayout()).apply { - val gap = UiStyle.Gap.lg() - rows.forEachIndexed { i, comp -> - add(comp, GridBagConstraints().apply { - gridx = 0; gridy = i - weightx = 1.0 - fill = GridBagConstraints.HORIZONTAL - anchor = GridBagConstraints.WEST - insets = if (i == 0) JBUI.emptyInsets() else JBUI.insetsTop(gap) - }) - } + private val content = Stack.vertical(UiStyle.Gap.lg()).apply { + next(header) + next(combo) + next(balanceCard) + next(actionRow.align(HAlign.CENTER, VAlign.CENTER)) } private var applying = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt index 80f870dbbf8..03326e588a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.icons.AllIcons import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.util.IconLoader import com.intellij.openapi.ui.popup.Balloon import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.ui.SimpleColoredComponent @@ -124,6 +125,12 @@ internal class LoggedOutProfileUi( foreground = UiStyle.Colors.weak() } + private val logoLabel = JBLabel(IconLoader.getIcon("/icons/kilo-profile.svg", LoggedOutProfileUi::class.java)).apply { + name = "kilo.profile.logo.loggedOut" + horizontalAlignment = SwingConstants.CENTER + accessibleContext.accessibleName = KiloBundle.message("settings.kilo.displayName") + } + private val errLabel = JBLabel().apply { foreground = UiStyle.Colors.errorLabelForeground() horizontalAlignment = SwingConstants.CENTER @@ -179,11 +186,12 @@ internal class LoggedOutProfileUi( private fun emptyCard(): JPanel { val p = padded() + p.add(logoLabel, gbc(0).centered()) p.add(JBLabel(KiloBundle.message("profile.notLoggedIn")).apply { foreground = UiStyle.Colors.weak() horizontalAlignment = SwingConstants.CENTER - }, gbc(0)) - p.add(loginBtn, gbc(1, UiStyle.Gap.sm()).centered()) + }, gbc(1, UiStyle.Gap.pad())) + p.add(loginBtn, gbc(2, UiStyle.Gap.sm()).centered()) return p } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile.svg new file mode 100644 index 00000000000..3c44cd2f564 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile_dark.svg new file mode 100644 index 00000000000..1b0773f2641 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-profile_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index c54efa1e297..4310d07140f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -70,7 +70,7 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index f99429a1679..d6e5b07c313 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -26,6 +26,14 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertTrue("Reinstall should force-enable recovery action", event.presentation.isEnabled) } + fun `test cli group has visible menu text`() { + val xml = requireNotNull(javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml")) + .bufferedReader() + .use { it.readText() } + + assertTrue(xml.contains("")) + } + private fun event(action: AnAction): AnActionEvent { val presentation = Presentation().apply { copyFrom(action.templatePresentation) } presentation.isEnabled = false diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index a3c8a93d072..82d843f9f8e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -96,6 +96,7 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(panel.reasoning.isVisible) assertEquals("high", panel.reasoning.selectedForTest()?.id) + assertEquals("High ▾", panel.reasoning.text) } fun `test reasoning picker aligns unchecked rows`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt index 1d6980cbe8a..66e0897e61f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt @@ -202,15 +202,18 @@ class ModelPickerTest : BasePlatformTestCase() { picker.setItems(listOf(item("a", "A", "openai", "OpenAI"))) assertEquals("openai/a", picker.selectionKeyForTest()) + assertEquals("A ▾", picker.text) } fun `test allowEmpty keeps empty selection`() { val picker = ModelPicker() picker.allowEmpty = true + picker.emptyText = "Not set" picker.setItems(listOf(item("a", "A", "openai", "OpenAI"))) assertNull(picker.selectionKeyForTest()) + assertEquals("Not set ▾", picker.text) } fun `test provider qualified default selects duplicate model id from correct provider`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt index ad553ea603a..898b54e61c6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt @@ -161,6 +161,14 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { panel.setSize(800, 600) layout(panel) + val logo = labelsByName(panel, "kilo.profile.logo.loggedIn").single() + val name = labels(panel).first { it.text == "Alice" } + val logoLoc = SwingUtilities.convertPoint(logo.parent, logo.location, panel) + val nameLoc = SwingUtilities.convertPoint(name.parent, name.location, panel) + assertNotNull(logo.icon) + assertTrue(logo.icon.iconWidth >= 64) + assertTrue(logoLoc.x > nameLoc.x) + val refresh = buttons(panel).first { it.text == "Refresh" } assertFalse(refresh.isContentAreaFilled) val card = refresh.parent @@ -208,6 +216,28 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { } } + fun `test logged out profile shows kilo icon above login content`() { + edt { + panel.update(null, KiloAppStatusDto.READY) + panel.setSize(800, 600) + layout(panel) + + val logo = labelsByName(panel, "kilo.profile.logo.loggedOut").single() + val label = labels(panel).first { it.text == "Not logged in" } + val btn = buttons(panel).first { it.text == "Login with Kilo Code" } + val logoLoc = SwingUtilities.convertPoint(logo.parent, logo.location, panel) + val labelLoc = SwingUtilities.convertPoint(label.parent, label.location, panel) + val btnLoc = SwingUtilities.convertPoint(btn.parent, btn.location, panel) + + assertTrue(visible(logo)) + assertNotNull(logo.icon) + assertTrue(logo.icon.iconWidth >= 64) + assertTrue(logo.icon.iconHeight >= 64) + assertTrue(logoLoc.y < labelLoc.y) + assertTrue(labelLoc.y < btnLoc.y) + } + } + fun `test account update retains name label`() { val alice = ProfileDto(email = "alice@test.com", name = "Alice") val bob = ProfileDto(email = "bob@test.com", name = "Bob") From 750bb778998a405719d3132d17867d2868e4defd Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 20:57:50 -0400 Subject: [PATCH 075/201] fix(jetbrains): confine session subscriptions to EDT --- .../jetbrains-controller-subscriptions.md | 5 +++ .../session/controller/SessionController.kt | 43 +++++++++++++------ .../session/controller/ChatLoggingFlowTest.kt | 7 +++ .../session/controller/PromptLifecycleTest.kt | 15 +++++++ specs/jetbrains-session-ui-findings-todo.md | 26 ++++++----- 5 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 .changeset/jetbrains-controller-subscriptions.md diff --git a/.changeset/jetbrains-controller-subscriptions.md b/.changeset/jetbrains-controller-subscriptions.md new file mode 100644 index 00000000000..d820e2ddb22 --- /dev/null +++ b/.changeset/jetbrains-controller-subscriptions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Improve JetBrains session stability by keeping controller subscription state on the UI thread. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 0075a889582..609cfdd45d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -50,6 +50,7 @@ import com.intellij.openapi.application.ApplicationManager import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -223,7 +224,10 @@ class SessionController( val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") capture("Task Created", sessionProps(session.id) + mapOf("source" to "jetbrains")) - subscribeEvents() + runEdt { + if (disposed) return@runEdt + subscribeEvents() + } session.id } sessions.prompt(id, directory, dto) @@ -458,7 +462,9 @@ class SessionController( } } + @RequiresEdt private fun drainAutoApprove(skip: Set = emptySet()) { + assertEdt() val id = sid ?: return val ids = (childIds + id).toSet() drainJob?.cancel() @@ -749,13 +755,12 @@ class SessionController( if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) } + @RequiresEdt private fun subscribeEvents() { + assertEdt() val id = sid ?: return LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=true" } - eventJob?.cancel() - childJobs.values.forEach { it.cancel() } - childJobs.clear() - childIds.clear() + cancelSubscriptions() eventJob = cs.launch { try { sessions.events(id, directory).collect { event -> @@ -772,7 +777,9 @@ class SessionController( } } + @RequiresEdt private fun subscribeChild(child: String) { + assertEdt() if (childJobs.containsKey(child)) return LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-subscription child=$child subscribe=true" } val job = cs.launch { @@ -789,12 +796,24 @@ class SessionController( childJobs[child] = job } + @RequiresEdt private fun trackChild(child: String) { + assertEdt() if (!childIds.add(child)) return subscribeChild(child) cs.launch { recoverChildPermissions(child) } } + @RequiresEdt + private fun cancelSubscriptions() { + assertEdt() + eventJob?.cancel() + eventJob = null + childJobs.values.forEach { it.cancel() } + childJobs.clear() + childIds.clear() + } + private suspend fun recoverChildPermissions(child: String) { try { val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child } @@ -1696,13 +1715,13 @@ class SessionController( } override fun dispose() { - disposed = true - connectionDelay.dispose() - eventJob?.cancel() - drainJob?.cancel() - childJobs.values.forEach { it.cancel() } - childJobs.clear() - childIds.clear() + runEdt { + disposed = true + connectionDelay.dispose() + cancelSubscriptions() + drainJob?.cancel() + drainJob = null + } cs.cancel() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt index 312913c29c1..1f5f444dd4f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt @@ -10,10 +10,16 @@ import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import kotlinx.coroutines.flow.flow +import java.util.concurrent.CopyOnWriteArrayList class ChatLoggingFlowTest : SessionControllerTestBase() { fun `test prompt creates session and subscribes before dispatch`() { + val calls = CopyOnWriteArrayList() + rpc.eventFlow = { id, _ -> + calls.add(id) + rpc.events + } projectRpc.state.value = workspaceReady() appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) val m = controller() @@ -23,6 +29,7 @@ class ChatLoggingFlowTest : SessionControllerTestBase() { flush() assertEquals(1, rpc.creates) + assertEquals(listOf("ses_test"), calls.toList()) assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 04aeadf6630..07dcfb934f3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -19,6 +19,7 @@ import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.ToolRefDto +import java.util.concurrent.CopyOnWriteArrayList class PromptLifecycleTest : SessionControllerTestBase() { @@ -496,6 +497,20 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("ses_child", perm.sessionId) } + fun `test repeated task part subscribes to child once`() { + val calls = CopyOnWriteArrayList() + rpc.eventFlow = { id, _ -> + calls.add(id) + rpc.events + } + prompted() + + emit(taskPart("ses_child"), flush = false) + emit(taskPart("ses_child")) + + assertEquals(1, calls.count { it == "ses_child" }) + } + fun `test child PermissionAsked moves root model to AwaitingPermission`() { val (m, _, _) = prompted() diff --git a/specs/jetbrains-session-ui-findings-todo.md b/specs/jetbrains-session-ui-findings-todo.md index c72e95cc9b1..7b97510fd91 100644 --- a/specs/jetbrains-session-ui-findings-todo.md +++ b/specs/jetbrains-session-ui-findings-todo.md @@ -4,31 +4,37 @@ Use this as a planning backlog for the JetBrains session UI performance, memory, Last status check: 2026-06-02. +## Current Summary + +- Implemented high-priority work: streaming markdown no longer rebuilds the full rendered tree on normal deltas, and hidden cached session UIs are disposed after a configurable timeout while `SessionUpdateQueue` uses a shared coroutine ticker instead of per-session scheduler threads. +- Remaining high-priority work: none. `SessionController` subscription-state mutation is now EDT-confined while RPC event collection remains on background coroutines. +- Remaining non-high-priority work: repaint/revalidate cleanup, lazy collapsed bodies, question editor disposal, EDT annotations/assertions, style callback disposal guards, `SessionUpdateQueue` Swing listener EDT confinement, question body retention, semantic timeline colors, and additional retained Swing regression tests. + ## High Priority - [x] Fix streaming markdown full-tree rebuilds - Severity: High - - Status: Addressed for the high-priority full-tree rebuild issue. Remaining repaint/revalidate cleanup is tracked separately under medium priority. + - Status: Implemented for the high-priority full-tree rebuild issue. Remaining repaint/revalidate cleanup is tracked separately under medium priority. - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt` - Issue: `ContentDelta` updates fetch full content and route through `TextView.update()`/`md.set()`, causing markdown to reparse and recreate all child components, including code editors, on each streaming chunk. - - Plan direction: Restore incremental delta handling where safe, avoid full `set()` for append-only text, and retain/reuse markdown/code block components where possible. - - Current evidence: `ContentDelta` carries `created`, `SessionMessageListPanel` routes normal deltas through `appendDelta()`, and `MdViewHybrid` retains compatible HTML/code block views instead of clearing all rendered blocks. + - Implemented: `ContentDelta` carries `created`, `SessionMessageListPanel` routes normal deltas through `appendDelta()`, `TextView.appendDelta()` calls `md.append(delta)`, and `MdViewHybrid` retains compatible HTML/code block views instead of clearing all rendered blocks. + - Tests/release note: `SessionMessageListPanelTest` covers preserving the `TextView` and markdown component during deltas; `.changeset/retained-jetbrains-markdown.md` documents the user-facing fix. - [x] Prevent hidden cached sessions from accumulating resources - Severity: High - - Status: Addressed with timed hidden-session disposal and shared coroutine-based queue ticking. + - Status: Implemented with timed hidden-session disposal and shared coroutine-based queue ticking. - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt` - Issue: Inactive cached session UIs keep controller subscriptions, scheduler threads, models, Swing trees, and pending event queues alive. Non-metadata events can accumulate while hidden. - - Plan direction: Bound hidden transcript event queues with unconditional timed disposal, and avoid one scheduler thread per session UI. - - Current evidence: `kilo.session.inactive.dispose` was removed, `kilo.session.inactive.disposeTimeoutMs` now defaults to 180000 ms, hidden cached `SessionUi` instances are disposed by an EDT timer after the timeout unless shown again, and `SessionUpdateQueue` now uses the controller coroutine scope for its ticker instead of creating a per-queue scheduler thread. + - Implemented: `kilo.session.inactive.dispose` was removed, `kilo.session.inactive.disposeTimeoutMs` now defaults to 180000 ms, hidden cached `SessionUi` instances are disposed by an EDT timer after the timeout unless shown again, and `SessionUpdateQueue` now uses the controller coroutine scope for its ticker instead of creating a per-queue scheduler thread. + - Tests/release note: `SessionSidePanelManagerTest` covers hidden timeout disposal, reopen-after-timeout recreation, busy hidden disposal, and permission hidden disposal; `.changeset/hidden-session-timers.md` documents the user-facing fix. -- [ ] Confine controller subscription mutation to EDT +- [x] Confine controller subscription mutation to EDT - Severity: High - - Status: Not addressed; still needs work. + - Status: Implemented with EDT-only subscription bookkeeping and background RPC event collection. - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt` - Issue: `prompt()` calls `subscribeEvents()` from a background coroutine after session creation; `subscribeEvents()` mutates `eventJob`, `childJobs`, and `childIds` without EDT confinement. - - Plan direction: Run subscription-state mutation on EDT or separate thread-safe subscription state from EDT-only controller state. - - Current evidence: `prompt()` still calls `subscribeEvents()` from inside `cs.launch`, and `subscribeEvents()` still mutates `eventJob`, `childJobs`, and `childIds` without an EDT assertion or EDT handoff. + - Implemented: `subscribeEvents()`, `subscribeChild()`, `trackChild()`, `drainAutoApprove()`, and subscription cleanup are `@RequiresEdt`/asserted EDT-only. New-session prompt creation hands subscription setup back to EDT before prompt dispatch, and `dispose()` clears subscription state on EDT before cancelling the coroutine scope. + - Tests/release note: `ChatLoggingFlowTest` covers new-session event subscription, `PromptLifecycleTest` covers duplicate child task parts subscribing once, and `.changeset/jetbrains-controller-subscriptions.md` documents the stability fix. ## Medium Priority From 138a58f80f75bb287f4b75b16a0bb04f11d09e88 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 21:07:20 -0400 Subject: [PATCH 076/201] feat(kilo-jetbrains): add settings config file actions --- .../jetbrains-settings-config-actions.md | 5 ++ .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 61 +++++++++++++++++ .../kilocode/client/KiloToolWindowFactory.kt | 1 - .../client/actions/OpenConfigActions.kt | 67 +++++++++++++++++++ .../client/actions/OpenSettingsAction.kt | 3 +- .../client/app/KiloWorkspaceService.kt | 50 ++++++++++++++ .../kilocode/client/session/SessionManager.kt | 2 + .../client/session/SessionSidePanelManager.kt | 1 + .../resources/kilo.jetbrains.frontend.xml | 17 +++-- .../resources/messages/KiloBundle.properties | 11 ++- .../client/actions/KiloRecoveryActionsTest.kt | 6 ++ .../client/testing/FakeWorkspaceRpcApi.kt | 26 +++++++ .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 12 ++++ 13 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 .changeset/jetbrains-settings-config-actions.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt diff --git a/.changeset/jetbrains-settings-config-actions.md b/.changeset/jetbrains-settings-config-actions.md new file mode 100644 index 00000000000..cb86d06039d --- /dev/null +++ b/.changeset/jetbrains-settings-config-actions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve the JetBrains tool window settings menu with config file shortcuts and separate CLI restart and reinstall actions. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 2cfefb8c997..208fcaad728 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -6,6 +6,8 @@ import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.cli.KiloBackendCliManager +import ai.kilocode.backend.cli.KiloCliConfigPath import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager @@ -39,6 +41,7 @@ import java.net.URI import java.net.URLDecoder import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.Path import kotlin.coroutines.resume @@ -53,6 +56,15 @@ import kotlin.coroutines.resume class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { companion object { private val LOG = KiloLog.create(KiloWorkspaceRpcApiImpl::class.java) + private const val SCHEMA = "https://app.kilo.ai/config.json" + private val MODERN = listOf("kilo.jsonc", "kilo.json") + private val LEGACY = listOf("opencode.jsonc", "opencode.json") + private val GLOBAL = MODERN + LEGACY + "config.json" + private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode") + private val CONFIG = """{ + "${'$'}schema": "$SCHEMA" +} +""" } private val app: KiloBackendAppService get() = service() @@ -171,6 +183,55 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { return true } + override suspend fun localConfigPath(directory: String): String = withContext(Dispatchers.IO) { + localConfig(directory).toString() + } + + override suspend fun globalConfigPath(): String = withContext(Dispatchers.IO) { + globalConfig().toString() + } + + override suspend fun openLocalConfig(directory: String): Boolean = openConfig(withContext(Dispatchers.IO) { + localConfig(directory) + }) + + override suspend fun openGlobalConfig(): Boolean = openConfig(withContext(Dispatchers.IO) { + globalConfig() + }) + + private suspend fun openConfig(path: Path): Boolean { + val target = withContext(Dispatchers.IO) { + Files.createDirectories(path.parent) + if (!Files.exists(path)) Files.writeString(path, CONFIG, StandardCharsets.UTF_8) + path + } + val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false + val project = project(target) ?: run { + LOG.warn("No project available to open config file: $target") + return false + } + navigate(project, vf) + return true + } + + private fun localConfig(directory: String): Path { + val root = file(clean(directory) ?: directory)?.takeIf { it.isAbsolute } ?: Path.of(directory).normalize() + val dirs = LOCAL_DIRS.map { root.resolve(it) } + root + val found = dirs.asSequence() + .flatMap { dir -> (MODERN + LEGACY).asSequence().map { name -> dir.resolve(name) } } + .firstOrNull { Files.exists(it) } + return found ?: root.resolve(".kilo").resolve("kilo.jsonc") + } + + private fun globalConfig(): Path { + val env = KiloBackendCliManager().buildEnv("config") + val root = KiloCliConfigPath.resolve(env).toPath().normalize() + return GLOBAL.asSequence() + .map { root.resolve(it) } + .firstOrNull { Files.exists(it) } + ?: root.resolve("kilo.jsonc") + } + private fun clean(path: String): String? { val raw = path.trim().takeIf { it.isNotBlank() } ?: return null return try { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index b3e083f0fb0..ecbe1f14f7d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -73,7 +73,6 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { val actions = listOfNotNull( ActionManager.getInstance().getAction("Kilo.NewSession"), ActionManager.getInstance().getAction("Kilo.History"), - ActionManager.getInstance().getAction("Kilo.ShowProfile"), ActionManager.getInstance().getAction("Kilo.Settings"), ) toolWindow.setTitleActions(actions) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt new file mode 100644 index 00000000000..2f3e2c848cb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt @@ -0,0 +1,67 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.telemetry.Telemetry +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware + +abstract class ConfigAction( + private val label: String, + text: String, + description: String, +) : AnAction(text, description, null), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + protected fun text(path: String?) = KiloBundle.message(label, path ?: "...") + + protected fun failed() { + KiloNotifications.error(KiloBundle.message("action.Kilo.OpenConfig.failed")) + } +} + +class OpenLocalConfigAction : ConfigAction( + label = "action.Kilo.OpenLocalConfig.text", + text = KiloBundle.message("action.Kilo.OpenLocalConfig.text", "..."), + description = KiloBundle.message("action.Kilo.OpenLocalConfig.description"), +) { + override fun update(e: AnActionEvent) { + val dir = directory(e) + e.presentation.isEnabled = dir != null + e.presentation.text = text(dir?.let { service().localConfigPath(it) }) + } + + override fun actionPerformed(e: AnActionEvent) { + val dir = directory(e) ?: return + Telemetry.send("Config Opened", mapOf("surface" to "tool_window", "scope" to "local")) + service().openLocalConfig(dir) { ok -> + if (!ok) failed() + } + } + + private fun directory(e: AnActionEvent): String? { + return e.getData(SessionManager.WORKSPACE_KEY)?.directory ?: e.project?.basePath + } +} + +class OpenGlobalConfigAction : ConfigAction( + label = "action.Kilo.OpenGlobalConfig.text", + text = KiloBundle.message("action.Kilo.OpenGlobalConfig.text", "..."), + description = KiloBundle.message("action.Kilo.OpenGlobalConfig.description"), +) { + override fun update(e: AnActionEvent) { + e.presentation.text = text(service().globalConfigPath()) + } + + override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("Config Opened", mapOf("surface" to "tool_window", "scope" to "global")) + service().openGlobalConfig { ok -> + if (!ok) failed() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt index c5e28f29d8a..64c41c00ab3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt @@ -3,7 +3,6 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.KiloSettingsConfigurable import ai.kilocode.client.telemetry.Telemetry -import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.options.Configurable @@ -15,7 +14,7 @@ import java.util.function.Predicate class OpenSettingsAction : DumbAwareAction( KiloBundle.message("action.Kilo.OpenSettings.text"), KiloBundle.message("action.Kilo.OpenSettings.description"), - AllIcons.General.GearPlain, + null, ) { override fun actionPerformed(e: AnActionEvent) { Telemetry.send("Settings Opened", mapOf("surface" to "tool_window")) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 99fc7aef8b5..31693e20190 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -41,6 +41,10 @@ class KiloWorkspaceService internal constructor( } private val workspaces = ConcurrentHashMap() + private val localConfig = ConcurrentHashMap() + + @Volatile + private var globalConfig: String? = null // ------ RPC helpers ------ @@ -129,4 +133,50 @@ class KiloWorkspaceService internal constructor( false } } + + fun localConfigPath(directory: String): String? { + cs.launch { + try { + localConfig[directory] = call { this.localConfigPath(directory) } + } catch (e: Exception) { + LOG.warn("local config lookup failed for directory=$directory", e) + } + } + return localConfig[directory] + } + + fun globalConfigPath(): String? { + cs.launch { + try { + globalConfig = call { this.globalConfigPath() } + } catch (e: Exception) { + LOG.warn("global config lookup failed", e) + } + } + return globalConfig + } + + fun openLocalConfig(directory: String, done: (Boolean) -> Unit) { + cs.launch { + val ok = try { + call { this.openLocalConfig(directory) } + } catch (e: Exception) { + LOG.warn("local config open failed for directory=$directory", e) + false + } + done(ok) + } + } + + fun openGlobalConfig(done: (Boolean) -> Unit) { + cs.launch { + val ok = try { + call { this.openGlobalConfig() } + } catch (e: Exception) { + LOG.warn("global config open failed", e) + false + } + done(ok) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index 0f67cd97ac0..08f2f575193 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -1,11 +1,13 @@ package ai.kilocode.client.session +import ai.kilocode.client.app.Workspace import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.actionSystem.DataKey interface SessionManager { companion object { val KEY = DataKey.create("ai.kilocode.client.session.SessionManager") + val WORKSPACE_KEY = DataKey.create("ai.kilocode.client.session.Workspace") } fun newSession() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 2a1a7e1f10d..97e0a5c6202 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -34,6 +34,7 @@ class SessionSidePanelManager( val component: JPanel = object : JPanel(BorderLayout()), DataProvider { override fun getData(dataId: String): Any? { if (SessionManager.KEY.`is`(dataId)) return this@SessionSidePanelManager + if (SessionManager.WORKSPACE_KEY.`is`(dataId)) return root return null } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 4310d07140f..bddd4a543bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -70,14 +70,27 @@ + + + + + + + + + + + @@ -91,14 +104,10 @@ - - - diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 0f67a78dacc..1891e237ccc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -259,8 +259,15 @@ action.Kilo.StopSession.text=Stop Session action.Kilo.StopSession.description=Stop the current Kilo session action.Kilo.SettingsGroup.text=Settings action.Kilo.SettingsGroup.description=Kilo Code settings -action.Kilo.OpenSettings.text=Open Settings -action.Kilo.OpenSettings.description=Open Kilo Code settings +action.Kilo.OpenSettings.text=Open Settings... +action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.OpenConfigGroup.text=Config Files +action.Kilo.OpenConfigGroup.description=Open Kilo config files +action.Kilo.OpenLocalConfig.text=Local {0} +action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file +action.Kilo.OpenGlobalConfig.text=Global {0} +action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file +action.Kilo.OpenConfig.failed=Failed to open Kilo config file action.Kilo.CliGroup.text=CLI action.Kilo.CliGroup.description=Kilo CLI actions action.Kilo.Restart.text=Restart Kilo diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index d6e5b07c313..d9485c135f5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -32,6 +32,12 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { .use { it.readText() } assertTrue(xml.contains("")) + assertTrue(xml.contains("")) + assertTrue(xml.contains("")) + assertTrue(xml.contains("")) + assertTrue(xml.contains("")) + assertFalse(xml.contains("")) } private fun event(action: AnAction): AnActionEvent { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 827307f5be8..f5564e965d4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -27,8 +27,12 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var modelsGate: CompletableDeferred? = null var fileMatches = emptyList() var openResult = true + var localConfigPath = "/test/.kilo/kilo.jsonc" + var globalConfigPath = "/config/kilo.jsonc" val fileCalls = mutableListOf>() val opened = mutableListOf() + val localConfigs = mutableListOf() + var globalConfigs = 0 override suspend fun resolveProjectDirectory(hint: String): String { assertNotEdt("resolveProjectDirectory") @@ -62,4 +66,26 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { opened.add(path) return openResult } + + override suspend fun localConfigPath(directory: String): String { + assertNotEdt("localConfigPath") + return localConfigPath + } + + override suspend fun globalConfigPath(): String { + assertNotEdt("globalConfigPath") + return globalConfigPath + } + + override suspend fun openLocalConfig(directory: String): Boolean { + assertNotEdt("openLocalConfig") + localConfigs.add(directory) + return openResult + } + + override suspend fun openGlobalConfig(): Boolean { + assertNotEdt("openGlobalConfig") + globalConfigs += 1 + return openResult + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index 4fd04426ccb..a34dc4be180 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -47,4 +47,16 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Open an absolute backend file path in the IDE. */ suspend fun openFile(path: String): Boolean + + /** Resolve the editable local config target path. */ + suspend fun localConfigPath(directory: String): String + + /** Resolve the editable global config target path. */ + suspend fun globalConfigPath(): String + + /** Open or create the local config file in the IDE. */ + suspend fun openLocalConfig(directory: String): Boolean + + /** Open or create the global config file in the IDE. */ + suspend fun openGlobalConfig(): Boolean } From 8c06a3b7840960798f1c4f4792b8eed1d366c3c4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 22:26:29 -0400 Subject: [PATCH 077/201] fix(jetbrains): improve session UI retained updates --- .changeset/jetbrains-session-ui-medium.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 4 + .../session/controller/SessionUpdateQueue.kt | 28 +++- .../client/session/model/SessionModel.kt | 23 +++ .../client/session/views/MessageView.kt | 1 - .../client/session/views/ReasoningView.kt | 138 +++++++++++----- .../kilocode/client/session/views/ToolView.kt | 149 +++++++++++------- .../views/base/AbstractSessionPartView.kt | 33 +++- .../session/views/base/BaseQuestionView.kt | 24 +-- .../views/base/SecondarySessionPartView.kt | 9 +- .../session/views/question/QuestionView.kt | 12 +- .../client/session/views/ReasoningViewTest.kt | 15 +- .../client/session/views/ToolViewTest.kt | 10 +- specs/jetbrains-session-ui-findings-todo.md | 28 ++-- 14 files changed, 333 insertions(+), 146 deletions(-) create mode 100644 .changeset/jetbrains-session-ui-medium.md diff --git a/.changeset/jetbrains-session-ui-medium.md b/.changeset/jetbrains-session-ui-medium.md new file mode 100644 index 00000000000..74754c3e08b --- /dev/null +++ b/.changeset/jetbrains-session-ui-medium.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains session UI stability and responsiveness during streaming updates and collapsed transcript rendering. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 6f27970002e..6c6eb54d294 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -457,11 +457,13 @@ class SessionUi( val bus = ApplicationManager.getApplication().messageBus.connect(this) bus.subscribe(EditorColorsManager.TOPIC, EditorColorsListener { ApplicationManager.getApplication().invokeLater { + if (disposed) return@invokeLater applyStyle(SessionEditorStyle.current()) } }) bus.subscribe(LafManagerListener.TOPIC, LafManagerListener { ApplicationManager.getApplication().invokeLater { + if (disposed) return@invokeLater applyStyle(SessionEditorStyle.current()) } }) @@ -546,6 +548,7 @@ class SessionUi( } override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return this.style = style selection.applyStyle(style) editorTheme = style.editorScheme @@ -562,6 +565,7 @@ class SessionUi( } private fun applyStyleIfThemeChanged() { + if (disposed) return val next = SessionEditorStyle.current() val laf = UIManager.getLookAndFeel() if (editorTheme === next.editorScheme && colorTheme == laf) return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt index e64a83c96f7..ad27ed5e218 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt @@ -37,10 +37,12 @@ internal class SessionUpdateQueue( private val condenser = SessionQueueCondenser() private val pending = mutableListOf() private val lock = Any() - private val visible = AtomicBoolean(comp?.isShowing ?: true) + private val disposed = AtomicBoolean(false) + private val visible = AtomicBoolean(comp == null) private val tick: Job? = if (flushMs == Long.MAX_VALUE) null else cs.launch { while (isActive) { delay(flushMs) + if (disposed.get()) continue if (!visible.get()) continue requestFlush(false, "tick") } @@ -56,10 +58,14 @@ internal class SessionUpdateQueue( init { Disposer.register(parent, this) - if (comp != null && watch != null) comp.addHierarchyListener(watch) + if (comp != null && watch != null) edt { + visible.set(comp.isShowing) + comp.addHierarchyListener(watch) + } } fun enqueue(event: ChatEventDto) { + if (disposed.get()) return if (!visible.get() && hidden(event)) { LOG.debug { "${ChatLogSummary.sid(sid())} enqueue hidden=true visible=false" } return @@ -74,6 +80,7 @@ internal class SessionUpdateQueue( } fun holdFlush(hold: Boolean) { + if (disposed.get()) return edt { LOG.debug { "${ChatLogSummary.sid(sid())} hold=$hold" } this.hold = hold @@ -81,23 +88,25 @@ internal class SessionUpdateQueue( } fun requestFlush(forced: Boolean, source: String = "api") { + if (disposed.get()) return if (!forced && !visible.get()) return edt { flushNow(forced, source) } } override fun dispose() { + if (!disposed.compareAndSet(false, true)) return val size = synchronized(lock) { pending.size } LOG.debug { "${ChatLogSummary.sid(sid())} dispose pending=$size" } tick?.cancel() - if (comp != null && watch != null) comp.removeHierarchyListener(watch) - if (app.isDispatchThread) { + val cleanup = { + if (comp != null && watch != null) comp.removeHierarchyListener(watch) synchronized(lock) { pending.clear() } - return } - app.invokeLater { synchronized(lock) { pending.clear() } } + if (app.isDispatchThread) cleanup() else app.invokeLater(cleanup) } private fun flushNow(forced: Boolean, source: String) { + if (disposed.get()) return if (hold) return if (!forced && !visible.get()) return val now = System.currentTimeMillis() @@ -116,6 +125,7 @@ internal class SessionUpdateQueue( } private fun onVisible(show: Boolean) { + if (disposed.get()) return val prev = visible.getAndSet(show) if (prev == show) return LOG.debug { "${ChatLogSummary.sid(sid())} visible=$show" } @@ -124,10 +134,14 @@ internal class SessionUpdateQueue( } private fun edt(block: () -> Unit) { + if (disposed.get()) return if (app.isDispatchThread) { block() return } - app.invokeLater(block) + app.invokeLater { + if (disposed.get()) return@invokeLater + block() + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index c8f4c665f03..0533705f7e9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import kotlin.math.roundToInt /** @@ -75,29 +76,38 @@ class SessionModel { private val listeners = mutableListOf() + @RequiresEdt fun addListener(parent: Disposable, listener: SessionModelEvent.Listener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } } + @RequiresEdt fun messages(): Collection = entries.values + @RequiresEdt fun message(id: String): Message? = entries[id] + @RequiresEdt fun content(messageId: String, contentId: String): Content? = entries[messageId]?.parts?.get(contentId) + @RequiresEdt fun turns(): Collection = turnEntries.values + @RequiresEdt fun turn(id: String): Turn? = turnEntries[id] + @RequiresEdt fun isEmpty(): Boolean = entries.isEmpty() + @RequiresEdt fun isReady(): Boolean = app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY /** * Add a message if it doesn't exist, or update its [MessageDto] info if it does. * Returns true when the message was newly added (caller can decide to show messages). */ + @RequiresEdt fun upsertMessage(dto: MessageDto): Boolean { val existing = entries[dto.id] if (existing != null) { @@ -116,6 +126,7 @@ class SessionModel { } /** @deprecated Use [upsertMessage] instead. Kept for incremental migration. */ + @RequiresEdt fun addMessage(dto: MessageDto): Message? { if (entries.containsKey(dto.id)) return null val msg = Message(dto) @@ -126,6 +137,7 @@ class SessionModel { return msg } + @RequiresEdt fun removeMessage(id: String) { if (entries.remove(id) == null) return fire(SessionModelEvent.MessageRemoved(id)) @@ -133,6 +145,7 @@ class SessionModel { updateHeader() } + @RequiresEdt fun removeContent(messageId: String, contentId: String) { val msg = entries[messageId] ?: return if (msg.parts.remove(contentId) == null) return @@ -140,6 +153,7 @@ class SessionModel { updateHeader() } + @RequiresEdt fun updateContent(messageId: String, dto: PartDto) { if (dto.type in SILENT_PART_TYPES) return val msg = entries[messageId] ?: return @@ -154,6 +168,7 @@ class SessionModel { updateHeader() } + @RequiresEdt fun appendDelta(messageId: String, contentId: String, delta: String) { val msg = entries[messageId] ?: return val existing = msg.parts[contentId] @@ -175,6 +190,7 @@ class SessionModel { updateHeader() } + @RequiresEdt fun setState(state: SessionState) { if (this.state == state) return this.state = state @@ -182,6 +198,7 @@ class SessionModel { updateHeader() } + @RequiresEdt fun setSession(session: SessionDto) { if (this.session == session) return this.session = session @@ -189,27 +206,32 @@ class SessionModel { updateHeader() } + @RequiresEdt fun setDiff(diff: List) { this.diff = diff fire(SessionModelEvent.DiffUpdated(diff)) } + @RequiresEdt fun setTodos(todos: List) { this.todos = todos fire(SessionModelEvent.TodosUpdated(todos)) updateHeader() } + @RequiresEdt fun markCompacted() { compactionCount++ fire(SessionModelEvent.Compacted(compactionCount)) updateHeader() } + @RequiresEdt fun refreshHeader() { updateHeader() } + @RequiresEdt fun loadHistory(history: List) { entries.clear() session = null @@ -231,6 +253,7 @@ class SessionModel { updateHeader() } + @RequiresEdt fun clear() { entries.clear() turnEntries.clear() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index cbec04f5efd..c9c418726bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -185,7 +185,6 @@ class MessageView( fun appendDelta(contentId: String, delta: String): Boolean { val part = parts[contentId] ?: return false part.appendDelta(delta) - refresh() return true } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index d7835d920f6..314c5d2b2aa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -28,37 +28,51 @@ import javax.swing.Scrollable /** Renders reasoning as a secondary collapsible block. */ class ReasoningView( reasoning: Reasoning, - openUrl: (String) -> Unit = {}, - selection: SessionSelection? = null, + private val openUrl: (String) -> Unit = {}, + private val selection: SessionSelection? = null, private val parts: ReasoningParts = reasoningParts(selection), ) : - SecondarySessionPartView(parts.header, parts.scroll) { + SecondarySessionPartView(parts.header, { parts.scroll(openUrl) }) { override val contentId: String = reasoning.id - val md: MdView = parts.md + val md: MdView + get() { + val fresh = !parts.bodyCreated() + val view = parts.md(openUrl) + if (!fresh) return view + registerBody(view) + view.set(source) + view.applyStyle(style) + apply(view) + return view + } private var style = SessionEditorStyle.current() private var source = reasoning.content.toString() + private var registered = false init { - Disposer.register(this, md) bindHeader(parts.title, parts.icon) applyStyle(style) - md.opaque = false - md.addLinkListener { openUrl(it.href) } - md.set(source) - parts.panel.add(md.component, BorderLayout.CENTER) sync() } + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + override fun update(content: Content) { if (content !is Reasoning) return var changed = false val next = content.content.toString() if (source != next) { source = next - md.set(source) + if (parts.bodyCreated()) md.set(source) changed = true } changed = sync() || changed @@ -68,7 +82,7 @@ class ReasoningView( override fun appendDelta(delta: String) { if (delta.isEmpty()) return source += delta - md.append(delta) + if (parts.bodyCreated()) md.append(delta) val changed = sync() if (changed || bodyVisible()) refresh() } @@ -77,10 +91,10 @@ class ReasoningView( fun hasToggle(): Boolean = arrow.isVisible fun headerText(): String = parts.title.text internal fun headerFont() = parts.title.font - internal fun bodyVisible() = parts.scroll.parent === this - internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy + internal fun bodyVisible() = parts.scrollOrNull?.parent === this + internal fun horizontalPolicy() = parts.scrollOrNull?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES - internal fun bodyCreated() = true + internal fun bodyCreated() = parts.bodyCreated() override fun applyStyle(style: SessionEditorStyle) { this.style = style @@ -89,8 +103,7 @@ class ReasoningView( parts.title.font = style.smallEditorFont changed = true } - md.applyStyle(style) - changed = apply(md) || changed + changed = applyBodyStyle() || changed if (changed) refresh() } @@ -117,8 +130,32 @@ class ReasoningView( return changed } - private fun bodyMaxHeight(): Int = md.component.getFontMetrics(md.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) + private fun syncBody() { + val md = md + registerBody(md) + md.set(source) + } + + private fun applyBodyStyle(): Boolean { + if (!parts.bodyCreated()) return false + val md = md + registerBody(md) + md.applyStyle(style) + return apply(md) + } + + private fun registerBody(md: MdView) { + if (registered) return + registered = true + Disposer.register(this, md) + } + + private fun bodyMaxHeight(): Int { + if (!parts.bodyCreated()) return 0 + val md = md + return md.component.getFontMetrics(md.font).height * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) + } override fun dumpLabel(): String { val state = if (bodyVisible()) "open" else "closed" @@ -127,32 +164,55 @@ class ReasoningView( } class ReasoningParts( - val md: MdView, - val panel: TrackPanel, - val scroll: JBScrollPane, val header: JPanel, val title: JBLabel, val icon: JBLabel, + private val selection: SessionSelection?, +) { + private var body: ReasoningBody? = null + val scrollOrNull: JBScrollPane? get() = body?.scroll + + fun bodyCreated() = body != null + + fun md(openUrl: (String) -> Unit): MdView = body(openUrl).md + + fun scroll(openUrl: (String) -> Unit): JBScrollPane = body(openUrl).scroll + + private fun body(openUrl: (String) -> Unit): ReasoningBody { + val item = body + if (item != null) return item + val md = MdViewFactory.create(SessionEditorStyle.current(), selection).apply { + opaque = false + addLinkListener { openUrl(it.href) } + } + val panel = TrackPanel().apply { + isOpaque = true + background = SessionUiStyle.View.surface() + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + ) + add(md.component, BorderLayout.CENTER) + } + val scroll = JBScrollPane(panel).apply { + border = SessionUiStyle.View.cardTop() + isOpaque = true + background = SessionUiStyle.View.surface() + viewport.background = SessionUiStyle.View.surface() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + return ReasoningBody(md, panel, scroll).also { body = it } + } +} + +class ReasoningBody( + val md: MdView, + val panel: TrackPanel, + val scroll: JBScrollPane, ) private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { - val md = MdViewFactory.create(SessionEditorStyle.current(), selection) - val panel = TrackPanel().apply { - isOpaque = true - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - val scroll = JBScrollPane(panel).apply { - border = SessionUiStyle.View.cardTop() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } val icon = JBLabel(AllIcons.General.InspectionsEye).apply { foreground = UiStyle.Colors.weak() } val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { @@ -160,7 +220,7 @@ private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts add(icon, BorderLayout.WEST) add(title, BorderLayout.CENTER) } - return ReasoningParts(md, panel, scroll, header, title, icon) + return ReasoningParts(header, title, icon, selection) } class TrackPanel : JPanel(BorderLayout()), Scrollable { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index b1736ce7282..371a97ea527 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -38,21 +38,28 @@ class ToolView( private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool), ) : - SecondarySessionPartView(parts.header, parts.scroll) { + SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { override val contentId: String = tool.id private var item = tool private var style = SessionEditorStyle.current() + private var registered = false init { - selection?.register(parts.text, this) bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - parts.text.text = preview(item) applyStyle(style) sync() } + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size @@ -79,20 +86,20 @@ class ToolView( fun outputText(): String = output(item) fun bodyText(): String = body(item) - internal fun previewText(): String = parts.text.text + internal fun previewText(): String = parts.text?.text ?: preview(item) fun hasToggle(): Boolean = arrow.isVisible - internal fun bodyFont() = parts.text.font + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont internal fun titleFont() = parts.title.font internal fun subtitleFont() = parts.sub.font internal fun stateFont() = parts.state.font - internal fun bodyEditable() = parts.text.isEditable - internal fun bodyCaretVisible() = parts.text.caret.isVisible - internal fun bodyVisible() = parts.scroll.parent === this + internal fun bodyEditable() = parts.text?.isEditable ?: false + internal fun bodyCaretVisible() = parts.text?.caret?.isVisible ?: false + internal fun bodyVisible() = parts.scroll?.parent === this internal fun controlCount() = if (arrow.isVisible) 1 else 0 - internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy - internal fun bodyWrap() = parts.text.lineWrap + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + internal fun bodyWrap() = parts.text?.lineWrap ?: true internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyCreated() = true + internal fun bodyCreated() = parts.bodyCreated() override fun applyStyle(style: SessionEditorStyle) { this.style = style @@ -101,7 +108,7 @@ class ToolView( changed = setFont(parts.sub, style.smallEditorFont) || changed changed = setFont(parts.link, style.smallEditorFont) || changed changed = setFont(parts.state, style.smallEditorFont) || changed - changed = setFont(parts.text, style.transcriptFont) || changed + changed = applyBodyStyle() || changed if (changed) refresh() } @@ -111,7 +118,8 @@ class ToolView( changed = syncExpandable(expand) || changed changed = setVisible(parts.state, !expand) || changed changed = syncLabels() || changed - changed = setForeground(parts.text, bodyColor()) || changed + val text = parts.text + if (text != null) changed = setForeground(text, bodyColor()) || changed return changed } @@ -129,20 +137,31 @@ class ToolView( private fun syncBody(): Boolean { var changed = false + val text = parts.text ?: return false val value = preview(item) - if (parts.text.text != value) { - parts.text.text = value - parts.text.caretPosition = 0 + if (text.text != value) { + text.text = value + text.caretPosition = 0 changed = true } - changed = setForeground(parts.text, bodyColor()) || changed + changed = setForeground(text, bodyColor()) || changed return changed } + private fun applyBodyStyle(): Boolean { + val text = parts.text ?: return false + if (!registered && selection != null && text.parent != null) { + registered = true + selection.register(text, this) + } + return setFont(text, style.transcriptFont) + } + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() private fun bodyMaxHeight(): Int { - return parts.text.getFontMetrics(parts.text.font).height * bodyMaxRows() + + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * bodyMaxRows() + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) } @@ -155,7 +174,7 @@ class ReadToolView( openFile: (String) -> Unit = {}, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, openFile), -) : SecondarySessionPartView(parts.header, parts.scroll, expandable = false) { +) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { companion object { fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ @@ -167,9 +186,9 @@ class ReadToolView( private var style = SessionEditorStyle.current() init { - selection?.register(parts.text, this) + parts.text?.let { selection?.register(it, this) } bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - parts.text.text = preview(item) + parts.text?.text = preview(item) applyStyle(style) sync() } @@ -193,11 +212,11 @@ class ReadToolView( .filter { it.isNotBlank() } .joinToString(" ") fun bodyText(): String = body(item) - internal fun bodyVisible() = parts.scroll.parent === this + internal fun bodyVisible() = parts.scroll?.parent === this internal fun hasToggle() = arrow.isVisible - internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyFont() = parts.text.font + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont internal fun linkVisible() = parts.link.isVisible internal fun linkText() = parts.label internal fun linkMarkup() = parts.link.text ?: "" @@ -215,7 +234,7 @@ class ReadToolView( changed = setFont(parts.sub, style.transcriptFont) || changed changed = setFont(parts.link, style.transcriptFont) || changed changed = setFont(parts.state, style.smallEditorFont) || changed - changed = setFont(parts.text, style.transcriptFont) || changed + parts.text?.let { changed = setFont(it, style.transcriptFont) || changed } if (changed) refresh() } @@ -232,7 +251,7 @@ class ReadToolView( changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed - changed = setForeground(parts.text, bodyColor()) || changed + parts.text?.let { changed = setForeground(it, bodyColor()) || changed } return changed } @@ -261,16 +280,18 @@ class ReadToolView( private fun syncBody(): Boolean { val value = preview(item) - if (parts.text.text == value) return false - parts.text.text = value - parts.text.caretPosition = 0 + val text = parts.text ?: return false + if (text.text == value) return false + text.text = value + text.caretPosition = 0 return true } private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() private fun bodyMaxHeight(): Int { - return parts.text.getFontMetrics(parts.text.font).height * bodyMaxRows() + + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * bodyMaxRows() + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) } @@ -287,19 +308,60 @@ class ToolParts( val state: JBLabel, val center: JPanel, val controls: JComponent, - val text: JBTextArea, - val scroll: JBScrollPane, private val open: ((String) -> Unit)? = null, ) { var href: String? = null var label: String = "" + private var body: ToolBody? = null + + val text: JBTextArea? + get() = body?.text + + val scroll: JBScrollPane? + get() = body?.scroll + + fun scroll(tool: Tool): JBScrollPane = body(tool).scroll + + fun bodyCreated() = body != null fun openLink() { val value = href ?: return open?.invoke(value) } + + private fun body(tool: Tool): ToolBody { + val item = body + if (item != null) return item + val text = JBTextArea().apply { + isEditable = false + caret.isVisible = false + caret.isSelectionVisible = true + lineWrap = true + wrapStyleWord = true + foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + background = SessionUiStyle.View.surface() + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + ) + } + val scroll = JBScrollPane(text).apply { + border = SessionUiStyle.View.cardTop() + isOpaque = true + background = SessionUiStyle.View.surface() + viewport.background = SessionUiStyle.View.surface() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + return ToolBody(text, scroll).also { body = it } + } } +class ToolBody( + val text: JBTextArea, + val scroll: JBScrollPane, +) + private const val SUB_CARD = "sub" private const val LINK_CARD = "link" @@ -327,27 +389,6 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar } val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { isOpaque = false } - val text = JBTextArea().apply { - isEditable = false - caret.isVisible = false - caret.isSelectionVisible = true - lineWrap = true - wrapStyleWord = true - foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - val scroll = JBScrollPane(text).apply { - border = SessionUiStyle.View.cardTop() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } val controls = Box.createHorizontalBox() val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { isOpaque = false @@ -357,7 +398,7 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar add(center, BorderLayout.CENTER) add(controls, BorderLayout.EAST) } - parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, text, scroll, openFile) + parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile) return parts.also { controls.add(it.state) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 4141eef1f76..c81828ab2bd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -16,14 +16,22 @@ import javax.swing.SwingUtilities abstract class AbstractSessionPartView( header: JComponent, - protected val body: JComponent, + private val makeBody: () -> JComponent, expanded: Boolean = false, private val expandable: Boolean = true, ) : PartView() { + constructor( + header: JComponent, + body: JComponent, + expanded: Boolean = false, + expandable: Boolean = true, + ) : this(header, { body }, expanded, expandable) + protected val arrow = JBLabel() protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)) private val bound = linkedSetOf() + private var body: JComponent? = null private val click = object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { @@ -49,11 +57,11 @@ abstract class AbstractSessionPartView( row.add(arrow, BorderLayout.EAST) add(row, BorderLayout.NORTH) bindHeader(row, header, arrow) - if (expanded && expandable) add(body, BorderLayout.CENTER) + if (expanded && expandable) add(body(), BorderLayout.CENTER) if (!expandable) syncExpandable(false) else syncArrow() } - fun isExpanded(): Boolean = body.parent === this + fun isExpanded(): Boolean = body?.parent === this fun toggle() { if (!expandable || !arrow.isVisible) return @@ -63,19 +71,24 @@ abstract class AbstractSessionPartView( refresh() } - fun expand(): Boolean { + open fun expand(): Boolean { if (!expandable) return false if (isExpanded()) return false - add(body, BorderLayout.CENTER) + add(body(), BorderLayout.CENTER) return true } fun collapse(): Boolean { - if (!isExpanded()) return false - remove(body) + val item = body ?: return false + if (item.parent !== this) return false + remove(item) return true } + protected fun hasBody(): Boolean = body != null + + protected fun bodyComponent(): JComponent = body() + fun syncExpandable(expandable: Boolean): Boolean { val active = this.expandable && expandable val changed = setVisible(arrow, active) @@ -119,6 +132,12 @@ abstract class AbstractSessionPartView( component.addMouseListener(mouse) } + private fun body(): JComponent { + val item = body + if (item != null) return item + return makeBody().also { body = it } + } + private fun syncCursor(cursor: Cursor): Boolean { var changed = false bound.forEach { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index d43a1aa6ae8..5b14587de93 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -98,6 +98,7 @@ class BaseQuestionView( // action buttons keyed by id for retained updates private val actionButtons = mutableMapOf() + private val actionHandlers = mutableMapOf Unit>() private val actionOrder = mutableListOf() private val mainActions = Stack.horizontal(gap = UiStyle.Gap.sm()) @@ -186,20 +187,25 @@ class BaseQuestionView( /** * Configure the action buttons shown in the card's right-aligned footer. * - * All buttons are created fresh; stable button references across calls can be - * maintained by the caller through [setActionEnabled] using the [Action.id]. + * Buttons are retained by stable [Action.id] when possible and updated in place. * Pass an empty list to remove the footer entirely. */ @RequiresEdt fun setActions(actions: List) { - actionButtons.clear() + val ids = actions.map { it.id }.toSet() + val stale = actionButtons.keys - ids + stale.forEach { + actionButtons.remove(it) + actionHandlers.remove(it) + } actionOrder.clear() mainActions.removeAll() for (action in actions) { - val btn = makeButton(action.text, action.primary).apply { - isEnabled = action.enabled - addActionListener { action.handler() } - } + val btn = actionButtons[action.id] ?: makeButton(action.id, action.text).also { actionButtons[action.id] = it } + actionHandlers[action.id] = action.handler + btn.text = action.text + btn.isEnabled = action.enabled + btn.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, if (action.primary) true else null) actionButtons[action.id] = btn actionOrder.add(action.id) mainActions.next(btn) @@ -365,10 +371,9 @@ class BaseQuestionView( if (area.font != font) area.font = font } - private fun makeButton(text: String, primary: Boolean): JButton { + private fun makeButton(id: String, text: String): JButton { val btn = object : JButton(text) { init { - if (primary) putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) syncBackground() } @@ -381,6 +386,7 @@ class BaseQuestionView( background = SessionUiStyle.View.surface() } } + btn.addActionListener { actionHandlers[id]?.invoke() } return btn } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt index ac99946eedc..4ec82d7163f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -6,10 +6,17 @@ import javax.swing.JComponent abstract class SecondarySessionPartView( header: JComponent, - content: JComponent, + content: () -> JComponent, expanded: Boolean = false, expandable: Boolean = true, ) : AbstractSessionPartView(header, content, expanded, expandable) { + + constructor( + header: JComponent, + content: JComponent, + expanded: Boolean = false, + expandable: Boolean = true, + ) : this(header, { content }, expanded, expandable) init { row.isOpaque = true row.background = SessionUiStyle.View.header() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index b0e6ecc8411..ec48efffc01 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -15,6 +15,7 @@ import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader @@ -148,7 +149,7 @@ class QuestionView( selections = emptyList() customTexts = emptyList() customOpen = emptyList() - customEditor = null + disposeCustomEditor() customFocus = null disposeRegs() texts.clear() @@ -177,7 +178,7 @@ class QuestionView( val q = question ?: return disposeRegs() texts.clear() - customEditor = null + disposeCustomEditor() customFocus = null body.removeAll() if (review(q)) { @@ -527,6 +528,13 @@ class QuestionView( return ed } + @RequiresEdt + private fun disposeCustomEditor() { + val ed = customEditor ?: return + customEditor = null + ed.getEditor(false)?.let { EditorFactory.getInstance().releaseEditor(it) } + } + @RequiresEdt private fun syncEditorHeight(ed: SessionEditorTextField) { val editor = ed.getEditor(false) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index a343491ba80..d5fcfd7167a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -19,7 +19,7 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals("one\ntwo\nthree\nfour", view.markdown()) assertTrue(view.hasToggle()) assertFalse(view.bodyVisible()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) } fun `test short completed reasoning is collapsible`() { @@ -81,32 +81,32 @@ class ReasoningViewTest : BasePlatformTestCase() { view.appendDelta("b") assertEquals("b", view.markdown()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) assertFalse(view.bodyVisible()) assertTrue(view.hasToggle()) } - fun `test collapsed append keeps eager reasoning body detached`() { + fun `test collapsed append keeps lazy reasoning body uncreated`() { val view = ReasoningView(reasoning("p1", done = false, text = "a")) view.appendDelta("b") assertEquals("ab", view.markdown()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) assertFalse(view.bodyVisible()) } - fun `test collapsed update keeps eager reasoning body detached`() { + fun `test collapsed update keeps lazy reasoning body uncreated`() { val view = ReasoningView(reasoning("p1", done = false, text = "a")) view.update(reasoning("p1", done = false, text = "abc")) assertEquals("abc", view.markdown()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) assertFalse(view.bodyVisible()) } - fun `test reasoning reuses eager markdown body`() { + fun `test reasoning creates lazy markdown body once`() { val view = ReasoningView(reasoning("p1", done = false, text = "one")) view.toggle() @@ -128,6 +128,7 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test reasoning markdown uses editor font settings`() { val style = SessionEditorStyle.current() val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + view.toggle() assertSmallItalicSheet(view.md.overrideSheet(), style) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index 4f373abf1d5..630e0e2863c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -74,7 +74,7 @@ class ToolViewTest : BasePlatformTestCase() { assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) assertFalse(view.bodyVisible()) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) view.toggle() assertTrue(view.bodyVisible()) assertTrue(view.bodyCreated()) @@ -122,14 +122,14 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(view.bodyVisible()) } - fun `test tool reuses eager body after collapse and expand`() { + fun `test tool creates lazy body once after collapse and expand`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" } val view = ToolView(t) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) view.toggle() val font = view.bodyFont() view.toggle() @@ -139,7 +139,7 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(view.bodyVisible()) } - fun `test collapsed update keeps eager tool body detached`() { + fun `test collapsed update keeps lazy tool body uncreated`() { val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" @@ -150,7 +150,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = "/home" }) - assertTrue(view.bodyCreated()) + assertFalse(view.bodyCreated()) assertEquals("$ pwd\n\n/home", view.bodyText()) } diff --git a/specs/jetbrains-session-ui-findings-todo.md b/specs/jetbrains-session-ui-findings-todo.md index 7b97510fd91..bba8971a249 100644 --- a/specs/jetbrains-session-ui-findings-todo.md +++ b/specs/jetbrains-session-ui-findings-todo.md @@ -38,47 +38,47 @@ Last status check: 2026-06-02. ## Medium Priority -- [ ] Remove repaint/revalidate cascades on streaming deltas +- [x] Remove repaint/revalidate cascades on streaming deltas - Severity: Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt` - Issue: A single content delta can call `refresh()` at child, message, and transcript levels. - - Plan direction: Let changed child views invalidate themselves only when size/paint changes; avoid parent refresh for delegated content updates. + - Implemented: `MessageView.appendDelta()` now delegates to the child part without refreshing the whole message card; leaf text/markdown views still invalidate themselves. -- [ ] Lazy-create collapsed tool and reasoning bodies +- [x] Lazy-create collapsed tool and reasoning bodies - Severity: Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt` - Issue: Collapsed and even non-expandable views eagerly create `JBTextArea`, `JBScrollPane`, and markdown bodies. - - Plan direction: Build only headers initially; create body components on first expansion or first direct access; avoid unused body for non-expandable read views. + - Implemented: collapsible secondary views support lazy body suppliers; `ToolView` and `ReasoningView` create text/markdown scroll bodies on first expansion or direct body access and reuse them after collapse. -- [ ] Explicitly dispose transient question custom editors +- [x] Explicitly dispose transient question custom editors - Severity: Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt` - Issue: `syncPage()` and `hideView()` remove/null `SessionEditorTextField` instances without explicit disposal of editor/listener resources. - - Plan direction: Track editor disposables, call the appropriate `EditorTextField` disposal mechanism, and test repeated custom-row toggle/navigation. + - Implemented: `QuestionView` releases the active custom `EditorTextField` editor before clearing or rebuilding the question page. -- [ ] Add EDT annotations/assertions to session model and UI paths +- [x] Add EDT annotations/assertions to session model and UI paths - Severity: Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt` - Issue: `SessionModel` is documented EDT-only, but public APIs and several Swing-mutating methods lack `@RequiresEdt` or assertions. - - Plan direction: Annotate EDT-only methods, add runtime assertions where useful, and ensure listener callbacks remain EDT-only. + - Implemented: public `SessionModel` read/mutation/listener APIs now carry `@RequiresEdt` contracts matching the documented ownership model. -- [ ] Guard queued style callbacks after disposal +- [x] Guard queued style callbacks after disposal - Severity: Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt` - Issue: theme/editor color listeners schedule `invokeLater { applyStyle(...) }` without checking `disposed` before mutating components. - - Plan direction: Check `disposed` in queued callbacks and at the start of `applyStyle()`. + - Implemented: queued editor/LAF style callbacks and style application paths now return early once `SessionUi` is disposed. -- [ ] Confine `SessionUpdateQueue` Swing listener operations to EDT +- [x] Confine `SessionUpdateQueue` Swing listener operations to EDT - Severity: Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt` - Issue: constructor reads `comp.isShowing` and adds a hierarchy listener without an EDT contract; `dispose()` removes the listener directly. - - Plan direction: Add `@RequiresEdt`/assertions for construction and disposal or marshal Swing listener operations to EDT. + - Implemented: `SessionUpdateQueue` marshals hierarchy listener add/remove and `isShowing` reads to EDT, and queued flush work is ignored after disposal. -- [ ] Reduce full body rebuilds in question UI +- [x] Reduce full body rebuilds in question UI - Severity: Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt`, `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt` - Issue: question navigation/toggle paths call `body.removeAll()` and recreate rows/buttons/listeners. - - Plan direction: Retain per-page controls where practical, update existing button/text state, and avoid rebuilding action buttons for simple label/enabled changes. + - Implemented: `BaseQuestionView` retains footer buttons by action id, updating text, enabled state, primary style, and handlers in place while still removing stale actions. ## Low Priority From d3e189a903ddd15887723c69e5b55d37a5bc6a28 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 22:36:14 -0400 Subject: [PATCH 078/201] fix(kilo-jetbrains): resolve config action labels --- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 15 +- .../client/actions/OpenConfigActions.kt | 24 +++- .../client/app/KiloWorkspaceService.kt | 37 ++--- .../resources/messages/KiloBundle.properties | 6 +- .../client/actions/KiloRecoveryActionsTest.kt | 132 +++++++++++++++++- .../client/testing/FakeWorkspaceRpcApi.kt | 23 ++- .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 9 +- .../ai/kilocode/rpc/dto/ConfigTargetDto.kt | 10 ++ 8 files changed, 212 insertions(+), 44 deletions(-) create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConfigTargetDto.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 208fcaad728..02ef792bfb0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -15,6 +15,7 @@ import ai.kilocode.backend.workspace.KiloWorkspaceState import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.KiloWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto @@ -25,6 +26,7 @@ import com.intellij.openapi.components.service import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.LocalFileSystem import kotlinx.coroutines.Dispatchers @@ -183,12 +185,12 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { return true } - override suspend fun localConfigPath(directory: String): String = withContext(Dispatchers.IO) { - localConfig(directory).toString() + override suspend fun localConfigTarget(directory: String): ConfigTargetDto = withContext(Dispatchers.IO) { + target(localConfig(directory)) } - override suspend fun globalConfigPath(): String = withContext(Dispatchers.IO) { - globalConfig().toString() + override suspend fun globalConfigTarget(): ConfigTargetDto = withContext(Dispatchers.IO) { + target(globalConfig()) } override suspend fun openLocalConfig(directory: String): Boolean = openConfig(withContext(Dispatchers.IO) { @@ -232,6 +234,11 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { ?: root.resolve("kilo.jsonc") } + private fun target(path: Path): ConfigTargetDto { + val raw = path.toString() + return ConfigTargetDto(raw, FileUtil.getLocationRelativeToUserHome(raw, false), Files.exists(path)) + } + private fun clean(path: String): String? { val raw = path.trim().takeIf { it.isNotBlank() } ?: return null return try { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt index 2f3e2c848cb..be9162cb942 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt @@ -5,20 +5,26 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.rpc.dto.ConfigTargetDto import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service import com.intellij.openapi.project.DumbAware +import kotlinx.coroutines.runBlocking abstract class ConfigAction( - private val label: String, + private val open: String, + private val create: String, text: String, description: String, ) : AnAction(text, description, null), DumbAware { override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT - protected fun text(path: String?) = KiloBundle.message(label, path ?: "...") + protected fun text(target: ConfigTargetDto?): String { + val key = if (target?.exists == false) create else open + return KiloBundle.message(key, target?.displayPath ?: "...") + } protected fun failed() { KiloNotifications.error(KiloBundle.message("action.Kilo.OpenConfig.failed")) @@ -26,14 +32,17 @@ abstract class ConfigAction( } class OpenLocalConfigAction : ConfigAction( - label = "action.Kilo.OpenLocalConfig.text", + open = "action.Kilo.OpenLocalConfig.text", + create = "action.Kilo.CreateLocalConfig.text", text = KiloBundle.message("action.Kilo.OpenLocalConfig.text", "..."), description = KiloBundle.message("action.Kilo.OpenLocalConfig.description"), ) { override fun update(e: AnActionEvent) { val dir = directory(e) e.presentation.isEnabled = dir != null - e.presentation.text = text(dir?.let { service().localConfigPath(it) }) + e.presentation.text = text(dir?.let { + runBlocking { service().localConfigTarget(it) } + }) } override fun actionPerformed(e: AnActionEvent) { @@ -50,12 +59,15 @@ class OpenLocalConfigAction : ConfigAction( } class OpenGlobalConfigAction : ConfigAction( - label = "action.Kilo.OpenGlobalConfig.text", + open = "action.Kilo.OpenGlobalConfig.text", + create = "action.Kilo.CreateGlobalConfig.text", text = KiloBundle.message("action.Kilo.OpenGlobalConfig.text", "..."), description = KiloBundle.message("action.Kilo.OpenGlobalConfig.description"), ) { override fun update(e: AnActionEvent) { - e.presentation.text = text(service().globalConfigPath()) + e.presentation.text = text( + runBlocking { service().globalConfigTarget() }, + ) } override fun actionPerformed(e: AnActionEvent) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 31693e20190..119b0deec32 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto @@ -41,10 +42,10 @@ class KiloWorkspaceService internal constructor( } private val workspaces = ConcurrentHashMap() - private val localConfig = ConcurrentHashMap() + private val localConfig = ConcurrentHashMap() @Volatile - private var globalConfig: String? = null + private var globalConfig: ConfigTargetDto? = null // ------ RPC helpers ------ @@ -134,26 +135,26 @@ class KiloWorkspaceService internal constructor( } } - fun localConfigPath(directory: String): String? { - cs.launch { - try { - localConfig[directory] = call { this.localConfigPath(directory) } - } catch (e: Exception) { - LOG.warn("local config lookup failed for directory=$directory", e) - } + suspend fun localConfigTarget(directory: String): ConfigTargetDto? { + return try { + val target = call { this.localConfigTarget(directory) } + localConfig[directory] = target + target + } catch (e: Exception) { + LOG.warn("local config lookup failed for directory=$directory", e) + localConfig[directory] } - return localConfig[directory] } - fun globalConfigPath(): String? { - cs.launch { - try { - globalConfig = call { this.globalConfigPath() } - } catch (e: Exception) { - LOG.warn("global config lookup failed", e) - } + suspend fun globalConfigTarget(): ConfigTargetDto? { + return try { + val target = call { this.globalConfigTarget() } + globalConfig = target + target + } catch (e: Exception) { + LOG.warn("global config lookup failed", e) + globalConfig } - return globalConfig } fun openLocalConfig(directory: String, done: (Boolean) -> Unit) { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 1891e237ccc..ed7316153ee 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -263,9 +263,11 @@ action.Kilo.OpenSettings.text=Open Settings... action.Kilo.OpenSettings.description=Open Kilo Code settings dialog action.Kilo.OpenConfigGroup.text=Config Files action.Kilo.OpenConfigGroup.description=Open Kilo config files -action.Kilo.OpenLocalConfig.text=Local {0} +action.Kilo.OpenLocalConfig.text=Open: local {0} +action.Kilo.CreateLocalConfig.text=Create: local {0} action.Kilo.OpenLocalConfig.description=Open or create the local Kilo config file -action.Kilo.OpenGlobalConfig.text=Global {0} +action.Kilo.OpenGlobalConfig.text=Open: global {0} +action.Kilo.CreateGlobalConfig.text=Create: global {0} action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config file action.Kilo.OpenConfig.failed=Failed to open Kilo config file action.Kilo.CliGroup.text=CLI diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index d9485c135f5..27007303101 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -1,18 +1,54 @@ package ai.kilocode.client.actions +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.AnAction 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.openapi.application.ApplicationManager +import com.intellij.testFramework.replaceService import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow @Suppress("UnstableApiUsage") class KiloRecoveryActionsTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeWorkspaceRpcApi() + ApplicationManager.getApplication().replaceService( + KiloWorkspaceService::class.java, + KiloWorkspaceService(scope, rpc), + testRootDisposable, + ) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + fun `test restart action stays enabled for all app states`() { val action = RestartKiloAction() val event = event(action) - ActionUtil.updateAction(action, event) + update(action, event) assertTrue("Restart should force-enable recovery action", event.presentation.isEnabled) } @@ -21,7 +57,7 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { val action = ReinstallKiloAction() val event = event(action) - ActionUtil.updateAction(action, event) + update(action, event) assertTrue("Reinstall should force-enable recovery action", event.presentation.isEnabled) } @@ -40,9 +76,97 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertFalse(xml.contains("")) } - private fun event(action: AnAction): AnActionEvent { + fun `test local config action says open when target exists`() { + rpc.localConfigPath = "/test/.kilo/kilo.jsonc" + rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" + rpc.localConfigExists = true + val action = OpenLocalConfigAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabled) + assertEquals("Open: local ~/.kilo/kilo.jsonc", event.presentation.text) + assertEquals(1, rpc.localConfigPathCalls) + } + + fun `test local config action says create when target is missing`() { + rpc.localConfigPath = "/test/.kilo/kilo.jsonc" + rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" + rpc.localConfigExists = false + val action = OpenLocalConfigAction() + val event = event(action, workspace = workspace("/test")) + + update(action, event) + + assertTrue(event.presentation.isEnabled) + assertEquals("Create: local ~/.kilo/kilo.jsonc", event.presentation.text) + assertEquals(1, rpc.localConfigPathCalls) + } + + fun `test global config action says open when target exists`() { + rpc.globalConfigPath = "/config/kilo.jsonc" + rpc.globalConfigDisplayPath = "~/.config/kilo/kilo.jsonc" + rpc.globalConfigExists = true + val action = OpenGlobalConfigAction() + val event = event(action) + + update(action, event) + + assertEquals("Open: global ~/.config/kilo/kilo.jsonc", event.presentation.text) + assertEquals(1, rpc.globalConfigPathCalls) + } + + fun `test global config action says create when target is missing`() { + rpc.globalConfigPath = "/config/kilo.jsonc" + rpc.globalConfigDisplayPath = "~/.config/kilo/kilo.jsonc" + rpc.globalConfigExists = false + val action = OpenGlobalConfigAction() + val event = event(action) + + update(action, event) + + assertEquals("Create: global ~/.config/kilo/kilo.jsonc", event.presentation.text) + assertEquals(1, rpc.globalConfigPathCalls) + } + + fun `test local config action disables without directory`() { + val action = OpenLocalConfigAction() + val event = event(action) + + update(action, event) + + assertFalse(event.presentation.isEnabled) + assertEquals(0, rpc.localConfigPathCalls) + } + + private fun event(action: AnAction, workspace: Workspace? = null): AnActionEvent { val presentation = Presentation().apply { copyFrom(action.templatePresentation) } presentation.isEnabled = false - return AnActionEvent.createFromDataContext("", presentation) { null } + return AnActionEvent.createFromDataContext("", presentation, context(workspace)) + } + + private fun update(action: AnAction, event: AnActionEvent) { + ApplicationManager.getApplication().executeOnPooledThread { + ActionUtil.updateAction(action, event) + }.get() + } + + private fun context(workspace: Workspace?): DataContext { + return DataContext { id -> + when (id) { + SessionManager.WORKSPACE_KEY.name -> workspace + CommonDataKeys.PROJECT.name -> project.takeIf { workspace != null } + else -> null + } + } + } + + private fun workspace(dir: String): Workspace { + return Workspace( + dir, + MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)), + reload = {}, + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index f5564e965d4..bfdb5daece6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto @@ -29,10 +30,18 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var openResult = true var localConfigPath = "/test/.kilo/kilo.jsonc" var globalConfigPath = "/config/kilo.jsonc" + var localConfigDisplayPath = localConfigPath + var globalConfigDisplayPath = globalConfigPath + var localConfigExists = true + var globalConfigExists = true val fileCalls = mutableListOf>() val opened = mutableListOf() val localConfigs = mutableListOf() var globalConfigs = 0 + var localConfigPathCalls = 0 + private set + var globalConfigPathCalls = 0 + private set override suspend fun resolveProjectDirectory(hint: String): String { assertNotEdt("resolveProjectDirectory") @@ -67,14 +76,16 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return openResult } - override suspend fun localConfigPath(directory: String): String { - assertNotEdt("localConfigPath") - return localConfigPath + override suspend fun localConfigTarget(directory: String): ConfigTargetDto { + assertNotEdt("localConfigTarget") + localConfigPathCalls += 1 + return ConfigTargetDto(localConfigPath, localConfigDisplayPath, localConfigExists) } - override suspend fun globalConfigPath(): String { - assertNotEdt("globalConfigPath") - return globalConfigPath + override suspend fun globalConfigTarget(): ConfigTargetDto { + assertNotEdt("globalConfigTarget") + globalConfigPathCalls += 1 + return ConfigTargetDto(globalConfigPath, globalConfigDisplayPath, globalConfigExists) } override suspend fun openLocalConfig(directory: String): Boolean { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index a34dc4be180..aea6c43e36a 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -1,5 +1,6 @@ package ai.kilocode.rpc +import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto @@ -48,11 +49,11 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Open an absolute backend file path in the IDE. */ suspend fun openFile(path: String): Boolean - /** Resolve the editable local config target path. */ - suspend fun localConfigPath(directory: String): String + /** Resolve the editable local config target. */ + suspend fun localConfigTarget(directory: String): ConfigTargetDto - /** Resolve the editable global config target path. */ - suspend fun globalConfigPath(): String + /** Resolve the editable global config target. */ + suspend fun globalConfigTarget(): ConfigTargetDto /** Open or create the local config file in the IDE. */ suspend fun openLocalConfig(directory: String): Boolean diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConfigTargetDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConfigTargetDto.kt new file mode 100644 index 00000000000..713b019f741 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConfigTargetDto.kt @@ -0,0 +1,10 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ConfigTargetDto( + val path: String, + val displayPath: String, + val exists: Boolean, +) From d47085948038b4538ea37e105dd54b122c6e9caa Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 22:41:37 -0400 Subject: [PATCH 079/201] fix(kilo-jetbrains): shorten cli action labels --- .../src/main/resources/messages/KiloBundle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index ed7316153ee..f37a2b15950 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -272,9 +272,9 @@ action.Kilo.OpenGlobalConfig.description=Open or create the global Kilo config f action.Kilo.OpenConfig.failed=Failed to open Kilo config file action.Kilo.CliGroup.text=CLI action.Kilo.CliGroup.description=Kilo CLI actions -action.Kilo.Restart.text=Restart Kilo +action.Kilo.Restart.text=Restart action.Kilo.Restart.description=Kill and restart the CLI process -action.Kilo.Reinstall.text=Reinstall Kilo +action.Kilo.Reinstall.text=Reinstall action.Kilo.Reinstall.description=Re-extract the CLI binary and restart action.Kilo.Session.Open.text=Open action.Kilo.Session.Open.description=Open the selected session From 87b0cf15ddfe29503fa0a4d72067bce050a1c432 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 22:59:03 -0400 Subject: [PATCH 080/201] fix(jetbrains): use semantic session timeline colors --- .changeset/semantic-jetbrains-timeline.md | 5 +++ .../client/session/ui/style/SessionUiStyle.kt | 12 +++--- .../session/SessionSidePanelManagerTest.kt | 41 +++++++++++++++++++ .../client/session/model/SessionModelTest.kt | 22 +++++++++- .../ui/header/SessionHeaderPanelTest.kt | 17 +++++++- .../client/session/views/QuestionViewTest.kt | 12 ++++++ .../client/session/views/TextViewTest.kt | 35 ++++++++++++++++ .../client/session/views/TurnViewTest.kt | 37 +++++++++++++++++ specs/jetbrains-session-ui-findings-todo.md | 11 ++--- 9 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 .changeset/semantic-jetbrains-timeline.md diff --git a/.changeset/semantic-jetbrains-timeline.md b/.changeset/semantic-jetbrains-timeline.md new file mode 100644 index 00000000000..4ae0ffed6a3 --- /dev/null +++ b/.changeset/semantic-jetbrains-timeline.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains session timeline colors to follow semantic theme keys. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index da11b8c78c1..1df64adacc1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -121,13 +121,13 @@ object SessionUiStyle { /** Colors for timeline/activity indicators in the session header. */ object Timeline { - val READ: Color = JBColor(Color(0x37, 0x94, 0xff), Color(0x37, 0x94, 0xff)) - val WRITE: Color = JBColor(Color(0x00, 0x7f, 0xd4), Color(0x00, 0x7f, 0xd4)) - val TOOL: Color = JBColor(Color(0x00, 0x7a, 0xcc), Color(0x00, 0x7a, 0xcc)) + val READ: Color = JBColor.namedColor("Kilo.Session.Timeline.Read", Color(0x37, 0x94, 0xff)) + val WRITE: Color = JBColor.namedColor("Kilo.Session.Timeline.Write", Color(0x00, 0x7f, 0xd4)) + val TOOL: Color = JBColor.namedColor("Kilo.Session.Timeline.Tool", Color(0x00, 0x7a, 0xcc)) val SUCCESS: Color = JBColor.namedColor("Label.successForeground", UIUtil.getLabelSuccessForeground()) - val ERROR: Color = JBColor(Color(0xf4, 0x87, 0x71), Color(0xf4, 0x87, 0x71)) - val TEXT: Color = JBColor(Color(0x9d, 0x9d, 0x9d), Color(0x9d, 0x9d, 0x9d)) - val STEP: Color = JBColor(Color(0x4d, 0x4d, 0x4d), Color(0x4d, 0x4d, 0x4d)) + val ERROR: Color = JBColor.namedColor("Kilo.Session.Timeline.Error", UIUtil.getErrorForeground()) + val TEXT: Color = JBColor.namedColor("Kilo.Session.Timeline.Text", UIUtil.getContextHelpForeground()) + val STEP: Color = JBColor.namedColor("Kilo.Session.Timeline.Step", JBColor.border()) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index b3013d9b21f..439c33afd8d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -23,6 +23,8 @@ import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto @@ -36,6 +38,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow import javax.swing.JLabel import javax.swing.JComponent import javax.swing.JPanel @@ -238,6 +241,29 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2", "/test" to "ses_1"), created) } + fun `test queued hidden transcript events are discarded after timeout disposal`() { + useShortInactiveDisposeTimeout() + val flow = MutableSharedFlow(extraBufferCapacity = 16) + rpc.eventFlow = { _, _ -> flow } + val manager = manager() + + manager.openSession(session("ses_1")) + val first = active(manager) + settle() + manager.openSession(session("ses_2")) + kotlinx.coroutines.runBlocking { + flow.emit(ChatEventDto.MessageUpdated("ses_1", msg("msg_hidden", "ses_1", "assistant"))) + flow.emit(ChatEventDto.PartDelta("ses_1", "msg_hidden", "txt_hidden", "text", "stale")) + } + settle() + manager.openSession(session("ses_1")) + val second = active(manager) + settle() + + assertNotSame(first, second) + assertTrue(empty(second)) + } + fun `test pending session is retained for history overlays before timeout`() { useLongInactiveDisposeTimeout() val history = JLabel("History") @@ -623,6 +649,14 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + private fun empty(panel: JPanel): Boolean { + var empty = false + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + empty = panel.controller().model.isEmpty() + } + return empty + } + private fun useShortInactiveDisposeTimeout() = setInactiveDisposeTimeout(10) private fun useLongInactiveDisposeTimeout() = setInactiveDisposeTimeout(60_000) @@ -682,6 +716,13 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { time = SessionTimeDto(created = 1.0, updated = 2.0), ) + private fun msg(id: String, session: String, role: String) = MessageDto( + id = id, + sessionID = session, + role = role, + time = MessageTimeDto(created = 1.0), + ) + private fun cloud(id: String) = CloudSessionDto( id = id, title = "Cloud $id", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index a691ddea409..6e5d17d406c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -16,10 +16,12 @@ import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer -import com.intellij.testFramework.UsefulTestCase +import com.intellij.testFramework.fixtures.BasePlatformTestCase -class SessionModelTest : UsefulTestCase() { +@Suppress("UnstableApiUsage") +class SessionModelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable @@ -50,6 +52,15 @@ class SessionModelTest : UsefulTestCase() { assertEquals(SessionState.Idle, model.state) } + fun `test model mutation works through EDT`() { + // The test fixture does not consistently throw for @RequiresEdt when called + // from a pooled thread, so keep this as a behavioral EDT contract check. + edt { model.addMessage(msg("on_edt", "assistant")) } + + assertNotNull(edt { model.message("on_edt") }) + assertTrue(events.any { it is SessionModelEvent.MessageAdded && it.info.info.id == "on_edt" }) + } + fun `test isReady requires app and workspace readiness`() { model.app = KiloAppStateDto(KiloAppStatusDto.READY) assertFalse(model.isReady()) @@ -883,4 +894,11 @@ class SessionModelTest : UsefulTestCase() { private fun assertModel(expected: String) { assertEquals(expected.trimIndent().trim(), model.toString().trim()) } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 100d1bb12ab..599f2ebed09 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionControllerTestBase import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageDto @@ -21,6 +22,7 @@ import java.awt.Color import java.awt.Point import java.awt.event.MouseEvent import java.awt.event.MouseWheelEvent +import javax.swing.UIManager class SessionHeaderPanelTest : SessionControllerTestBase() { @@ -120,7 +122,20 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { emit(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("Done", "completed", "high")))) assertEquals("All 1 todos complete", panel.todoText()) - assertEquals(ai.kilocode.client.session.ui.style.SessionUiStyle.Timeline.SUCCESS, panel.foregrounds()[3]) + assertEquals(SessionUiStyle.Timeline.SUCCESS, panel.foregrounds()[3]) + } + + fun `test timeline colors honor semantic named color keys`() { + val old = UIManager.getColor("Kilo.Session.Timeline.Read") + val color = Color(12, 34, 56) + + try { + UIManager.put("Kilo.Session.Timeline.Read", color) + + assertEquals(color.rgb, SessionUiStyle.Timeline.READ.rgb) + } finally { + UIManager.put("Kilo.Session.Timeline.Read", old) + } } fun `test retained labels update on later header event`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index aba4646d732..6efd3d240c1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -706,6 +706,18 @@ class QuestionViewTest : BasePlatformTestCase() { assertNull("Empty custom editor should be removed after selecting a normal option", findAll(view).firstOrNull { it.parent != null }) } + fun `test empty custom editor is detached after forcing underlying editor creation`() { + view.show(customSingleQuestion("q_custom_editor_release")) + findAll(view).first { it.actionCommand == "" }.doClick() + val field = findAll(view).first() + val editor = field.getEditor(true) + assertSame(editor, field.getEditor(false)) + + option(view, "Minimal").doClick() + + assertNull(SwingUtilities.getAncestorOfClass(QuestionView::class.java, field)) + } + fun `test focusing retained custom editor reselects custom response`() { view.show(customSingleQuestion("q_custom_focus")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index 37f37965485..64c6e8db68f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -3,6 +3,8 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.JComponent +import javax.swing.RepaintManager /** * Tests for [TextView]. @@ -63,6 +65,24 @@ class TextViewTest : BasePlatformTestCase() { assertEquals("first second", view.markdown()) } + fun `test appendDelta empty string does not repaint or change markdown`() { + val view = TextView(Text("p1").also { it.content.append("keep") }) + val repaint = TrackingRepaintManager(view) + val old = RepaintManager.currentManager(view) + + try { + RepaintManager.setCurrentManager(repaint) + + view.appendDelta("") + + assertEquals("keep", view.markdown()) + assertEquals(0, repaint.dirty) + assertEquals(0, repaint.invalid) + } finally { + RepaintManager.setCurrentManager(old) + } + } + // ---- contentId ------ fun `test contentId matches Text id`() { @@ -123,4 +143,19 @@ class TextViewTest : BasePlatformTestCase() { assertEquals(listOf("https://kilocode.ai/docs"), urls) } + + private class TrackingRepaintManager(private val watched: JComponent) : RepaintManager() { + var dirty = 0 + var invalid = 0 + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c === watched) dirty++ + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent === watched) invalid++ + super.addInvalidComponent(invalidComponent) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 8887e216639..9010598a655 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -11,6 +11,9 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.RepaintManager /** * Tests for [TurnView] and [MessageView]. @@ -191,6 +194,25 @@ class TurnViewTest : BasePlatformTestCase() { mv.appendDelta("unknown", "delta") } + fun `test appendDelta for unknown part id does not repaint message or parent`() { + val parent = JPanel() + val mv = MessageView(msg("a1", "assistant"), openFile) + parent.add(mv) + val repaint = TrackingRepaintManager(setOf(parent, mv)) + val old = RepaintManager.currentManager(parent) + + try { + RepaintManager.setCurrentManager(repaint) + + assertFalse(mv.appendDelta("unknown", "delta")) + + assertTrue(repaint.dirty.isEmpty()) + assertTrue(repaint.invalid.isEmpty()) + } finally { + RepaintManager.setCurrentManager(old) + } + } + fun `test MessageView pre-populates parts from Message on creation`() { val message = msg("a1", "assistant") val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") } @@ -240,4 +262,19 @@ class TurnViewTest : BasePlatformTestCase() { private fun msg(id: String, role: String): Message = Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0))) + + private class TrackingRepaintManager(private val watched: Set) : RepaintManager() { + val dirty = mutableListOf() + val invalid = mutableListOf() + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c in watched) dirty.add(c) + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent in watched) invalid.add(invalidComponent) + super.addInvalidComponent(invalidComponent) + } + } } diff --git a/specs/jetbrains-session-ui-findings-todo.md b/specs/jetbrains-session-ui-findings-todo.md index bba8971a249..47c1240cecb 100644 --- a/specs/jetbrains-session-ui-findings-todo.md +++ b/specs/jetbrains-session-ui-findings-todo.md @@ -8,7 +8,7 @@ Last status check: 2026-06-02. - Implemented high-priority work: streaming markdown no longer rebuilds the full rendered tree on normal deltas, and hidden cached session UIs are disposed after a configurable timeout while `SessionUpdateQueue` uses a shared coroutine ticker instead of per-session scheduler threads. - Remaining high-priority work: none. `SessionController` subscription-state mutation is now EDT-confined while RPC event collection remains on background coroutines. -- Remaining non-high-priority work: repaint/revalidate cleanup, lazy collapsed bodies, question editor disposal, EDT annotations/assertions, style callback disposal guards, `SessionUpdateQueue` Swing listener EDT confinement, question body retention, semantic timeline colors, and additional retained Swing regression tests. +- Remaining findings: none. Semantic timeline colors and retained Swing regression coverage are complete. ## High Priority @@ -82,17 +82,18 @@ Last status check: 2026-06-02. ## Low Priority -- [ ] Replace hardcoded timeline runtime colors with semantic named colors +- [x] Replace hardcoded timeline runtime colors with semantic named colors - Severity: Low/Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt` - Issue: timeline color tokens use numeric `JBColor(Color(...), Color(...))`; AGENTS.md prefers theme-derived or `JBColor.namedColor(...)` semantic keys. - - Plan direction: Introduce named color keys with appropriate fallbacks or map to existing platform theme colors. + - Implemented: timeline tokens now use `JBColor.namedColor(...)` keys under `Kilo.Session.Timeline.*`, with platform semantic fallbacks for success/error/text/step and centralized blue fallbacks for read/write/tool. + - Tests/release note: `SessionHeaderPanelTest` verifies timeline colors honor semantic UIManager named-color overrides; `.changeset/semantic-jetbrains-timeline.md` documents the user-facing theming improvement. -- [ ] Add focused regression tests for retained Swing behavior +- [x] Add focused regression tests for retained Swing behavior - Severity: Low - Files: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/**` - Issue: Several risks need tests: no parent refresh on no-op deltas, lazy body creation, editor disposal, hidden-session queue bounds, and EDT assertions. - - Plan direction: Extend existing session UI/controller tests using real IntelliJ EDT fixtures, not mocks. + - Implemented: added no-op repaint coverage for unknown message deltas and empty text deltas, custom question editor detachment coverage after forcing lazy editor creation, hidden queued transcript discard coverage after timeout disposal, and an EDT assertion regression for `SessionModel` mutation. ## Confirmed OK From e86d44514b8bdce5dd543dba144734f0234ecdb1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 23:02:16 -0400 Subject: [PATCH 081/201] fix(kilo-jetbrains): restore last kilo settings page --- .../client/actions/OpenSettingsAction.kt | 9 ++-- .../client/settings/KiloSettingsSelection.kt | 21 +++++++++ .../settings/KiloSettingsSelectionTest.kt | 43 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsSelection.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsSelectionTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt index 64c41c00ab3..1f298ee8c70 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSettingsAction.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.settings.KiloSettingsConfigurable +import ai.kilocode.client.settings.KiloSettingsSelection import ai.kilocode.client.telemetry.Telemetry import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent @@ -9,6 +9,7 @@ import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.ProjectManager import java.util.function.Predicate class OpenSettingsAction : DumbAwareAction( @@ -18,10 +19,12 @@ class OpenSettingsAction : DumbAwareAction( ) { override fun actionPerformed(e: AnActionEvent) { Telemetry.send("Settings Opened", mapOf("surface" to "tool_window")) + val project = e.project ?: ProjectManager.getInstance().defaultProject + val target = KiloSettingsSelection.target(project) ShowSettingsUtil.getInstance().showSettingsDialog( - e.project, + project, Predicate { cfg: Configurable -> - cfg is ConfigurableWithId && cfg.getId() == KiloSettingsConfigurable.ID + cfg is ConfigurableWithId && cfg.getId() == target }, null, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsSelection.kt new file mode 100644 index 00000000000..9c180606bad --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsSelection.kt @@ -0,0 +1,21 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.project.Project + +internal object KiloSettingsSelection { + // IntelliJ persists the selected settings page with SettingsEditor.SELECTED_CONFIGURABLE. + const val SELECTED_CONFIGURABLE_KEY = "settings.editor.selected.configurable" + + fun target(project: Project): String { + val id = PropertiesComponent.getInstance(project).getValue(SELECTED_CONFIGURABLE_KEY) + if (id != null && isKilo(id)) return id + return UserProfileConfigurable.ID + } + + private fun isKilo(id: String?): Boolean { + if (id == KiloSettingsConfigurable.ID) return true + return id?.startsWith("${KiloSettingsConfigurable.ID}.") == true + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsSelectionTest.kt new file mode 100644 index 00000000000..5e2fa618a9b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsSelectionTest.kt @@ -0,0 +1,43 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.settings.models.ModelsConfigurable +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.ide.util.PropertiesComponent +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class KiloSettingsSelectionTest : BasePlatformTestCase() { + + override fun tearDown() { + try { + PropertiesComponent.getInstance(project).unsetValue(KiloSettingsSelection.SELECTED_CONFIGURABLE_KEY) + } finally { + super.tearDown() + } + } + + fun `test falls back to profile when no last settings page exists`() { + assertEquals(UserProfileConfigurable.ID, KiloSettingsSelection.target(project)) + } + + fun `test falls back to profile when last page is not kilo`() { + select("preferences.lookFeel") + + assertEquals(UserProfileConfigurable.ID, KiloSettingsSelection.target(project)) + } + + fun `test keeps last kilo root page`() { + select(KiloSettingsConfigurable.ID) + + assertEquals(KiloSettingsConfigurable.ID, KiloSettingsSelection.target(project)) + } + + fun `test keeps last kilo child page`() { + select(ModelsConfigurable.ID) + + assertEquals(ModelsConfigurable.ID, KiloSettingsSelection.target(project)) + } + + private fun select(id: String) { + PropertiesComponent.getInstance(project).setValue(KiloSettingsSelection.SELECTED_CONFIGURABLE_KEY, id) + } +} From 7c121dbbfda0d529dfd7b31550f9ed3e4dc83742 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 23:07:34 -0400 Subject: [PATCH 082/201] fix(cli): update worktree family effect test --- .../opencode/src/kilocode/worktree-family.ts | 4 +- .../worktree-family-submodule.test.ts | 46 ++++++++++++++----- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/kilocode/worktree-family.ts b/packages/opencode/src/kilocode/worktree-family.ts index 5a55c1f54f5..f95a5e53865 100644 --- a/packages/opencode/src/kilocode/worktree-family.ts +++ b/packages/opencode/src/kilocode/worktree-family.ts @@ -31,10 +31,10 @@ export namespace WorktreeFamily { // In a git submodule, `git worktree list --porcelain` reports the // gitdir (`/.git/modules/`) instead of the actual working // tree, so the parsed list never contains the directory sessions are - // recorded under. Including Instance.worktree keeps submodule sessions + // recorded under. Including ctx.worktree keeps submodule sessions // in scope without affecting normal repos (already present) or linked // worktrees (also already present). - dirs.push(Filesystem.resolve(Instance.worktree)) + dirs.push(Filesystem.resolve(ctx.worktree)) return [...new Set(dirs)] } } diff --git a/packages/opencode/test/kilocode/worktree-family-submodule.test.ts b/packages/opencode/test/kilocode/worktree-family-submodule.test.ts index 42a81eafeb6..b2143782334 100644 --- a/packages/opencode/test/kilocode/worktree-family-submodule.test.ts +++ b/packages/opencode/test/kilocode/worktree-family-submodule.test.ts @@ -1,14 +1,39 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { Effect, ManagedRuntime } from "effect" import * as fs from "fs/promises" import path from "path" -import { Instance } from "../../src/project/instance" +import { Git } from "../../src/git" import { WorktreeFamily } from "../../src/kilocode/worktree-family" +import { Project } from "../../src/project/project" import * as Log from "@opencode-ai/core/util/log" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" Log.init({ print: false }) +const project = Project.Service.of({ + init: () => Effect.void, + fromDirectory: () => Effect.die(new Error("unused")), + discover: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + update: () => Effect.die(new Error("unused")), + initGit: (input) => Effect.succeed(input.project), + setInitialized: () => Effect.void, + sandboxes: () => Effect.succeed([]), + addSandbox: () => Effect.void, + removeSandbox: () => Effect.void, +}) + +async function withGit(body: (rt: ManagedRuntime.ManagedRuntime) => Promise) { + const rt = ManagedRuntime.make(Git.defaultLayer) + try { + return await body(rt) + } finally { + await rt.dispose() + } +} + afterEach(async () => { await disposeAllInstances() }) @@ -26,15 +51,14 @@ describe("WorktreeFamily.list — git submodule", () => { const submodule = path.join(parent.path, "sub") const submoduleReal = await fs.realpath(submodule) - await Instance.provide({ - directory: submodule, - fn: async () => { - const dirs = await WorktreeFamily.list() - // `git worktree list --porcelain` from inside a submodule reports the - // gitdir (`/.git/modules/sub`) as the worktree, so without the - // submodule guard the actual working tree is missing. - expect(dirs).toContain(submoduleReal) - }, + await withGit(async (rt) => { + const dirs = await rt.runPromise( + WorktreeFamily.list().pipe(Effect.provideService(Project.Service, project), provideInstance(submodule)), + ) + // `git worktree list --porcelain` from inside a submodule reports the + // gitdir (`/.git/modules/sub`) as the worktree, so without the + // submodule guard the actual working tree is missing. + expect(dirs).toContain(submoduleReal) }) }) }) From cf9a0a4881d84d4a9122f38e796eecbe05607794 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 23:31:32 -0400 Subject: [PATCH 083/201] fix(kilo-jetbrains): preserve empty model selection --- .../kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index e0729124cfa..844ea8f3d70 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -117,7 +117,7 @@ class ModelPicker : PickerButton() { cursor = if (allowEmpty) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() return } - val item = selected ?: items.firstOrNull() + val item = selected ?: if (allowEmpty) null else items.firstOrNull() text = if (item == null && allowEmpty) "$emptyText ▾" else "${ModelText.sanitize(item?.display ?: items.first().display)} ▾" icon = if (item?.let(ModelText::collectsData) == true) AllIcons.General.Warning else null horizontalTextPosition = SwingConstants.LEFT From 7d1f6ae5fff53ae35c1df9426e884a80beb39a16 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 23:32:40 -0400 Subject: [PATCH 084/201] fix(cli): type zstd worker helpers --- .../src/kilocode/session-export/worker/zstd.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/kilocode/session-export/worker/zstd.ts b/packages/opencode/src/kilocode/session-export/worker/zstd.ts index f169236e0f6..25662837416 100644 --- a/packages/opencode/src/kilocode/session-export/worker/zstd.ts +++ b/packages/opencode/src/kilocode/session-export/worker/zstd.ts @@ -1,8 +1,15 @@ -import { zstdCompress, zstdDecompress } from "node:zlib" import { promisify } from "node:util" +import * as zlib from "node:zlib" -const compress = promisify(zstdCompress) -const decompress = promisify(zstdDecompress) +type Zstd = (bytes: Uint8Array, cb: (err: Error | null, output: Uint8Array) => void) => void + +const api = zlib as unknown as { + zstdCompress: Zstd + zstdDecompress: Zstd +} + +const compress = promisify(api.zstdCompress) +const decompress = promisify(api.zstdDecompress) export async function compressZstd(bytes: Uint8Array): Promise { return compress(bytes) as Promise From 2a05e02528f00441232c9f1b1dbe6bce40f2ad63 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 23:36:24 -0400 Subject: [PATCH 085/201] fix(jetbrains): reuse editor style font in markdown --- .../main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index e9740eaa409..332a40a211f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -594,7 +594,7 @@ internal class MdViewHybrid( area.isOpaque = opts.opaque area.background = opts.preBg area.foreground = opts.preFg - area.font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + area.font = style.transcriptFont } private inner class CodeField(file: FileType, opts: MdStyle, value: String) : @@ -607,7 +607,7 @@ internal class MdViewHybrid( ) { init { setFontInheritedFromLAF(false) - font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + font = style.transcriptFont addSettingsProvider { ed -> style.applyToEditor(ed) ed.setBorder(JBUI.Borders.empty()) @@ -877,7 +877,7 @@ internal class MdViewHybrid( val view = pane.viewport.view when (view) { is CodeField -> { - view.font = Font(opts.codeFont, Font.PLAIN, style.editorSize) + view.font = style.transcriptFont view.background = opts.preBg } is JBTextArea -> styleTextArea(view, opts) From 3462fa0122d17ac3521487742a76df1f2fbe84a6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 23:59:53 -0400 Subject: [PATCH 086/201] fix(kilo-jetbrains): address config review feedback --- .../backend/cli/KiloBackendCliManager.kt | 149 ++++++++++-------- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 4 +- .../client/actions/OpenConfigActions.kt | 9 +- .../client/app/KiloWorkspaceService.kt | 5 +- 4 files changed, 86 insertions(+), 81 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index 564d342590e..4dcef11b973 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -35,7 +35,6 @@ class KiloBackendCliManager( companion object { private const val STARTUP_TIMEOUT_MS = 30_000L private const val KILL_TIMEOUT_SECONDS = 5L - private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask","bash":"ask"}}""" private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""") } @@ -123,47 +122,8 @@ class KiloBackendCliManager( } // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). - internal fun buildEnv(pwd: String, base: Map = System.getenv()): Map = buildMap { - putAll(base) - put("KILO_SERVER_PASSWORD", pwd) - put("KILO_CLIENT", "jetbrains") - put("KILO_ENABLE_QUESTION_TOOL", "true") - put("KILO_PLATFORM", "jetbrains") - put("KILO_APP_NAME", "kilo-code") - put("KILO_TELEMETRY_LEVEL", if (KiloDevMode.enabled()) "off" else "all") - put("KILO_DISABLE_CLAUDE_CODE", "true") - put("KILOCODE_FEATURE", "jetbrains-plugin") - putIfAbsent("KILO_CONFIG_CONTENT", DEFAULT_CONFIG) - ideEnv().forEach { (k, v) -> put(k, v) } - devStorageEnv()?.forEach { (k, v) -> put(k, v) } - } - - private fun devStorageEnv(): Map? { - val enabled = System.getProperty("kilo.dev.storage.isolated", "false").toBoolean() - if (!enabled) return null - val root = System.getProperty("kilo.dev.worktree.root") ?: run { - log.warn("kilo.dev.storage.isolated=true but kilo.dev.worktree.root is not set; skipping dev storage isolation") - return null - } - val dev = File(root, ".kilo-dev") - val data = File(dev, "data") - val config = File(dev, "config") - val state = File(dev, "state") - val cache = File(dev, "cache") - for (dir in listOf(data, config, state, cache)) { - if (!dir.mkdirs() && !dir.isDirectory) { - log.warn("Failed to create dev storage dir ${dir.absolutePath}; skipping dev storage isolation") - return null - } - } - log.info("Dev storage isolation enabled under ${dev.absolutePath}") - return mapOf( - "XDG_DATA_HOME" to data.absolutePath, - "XDG_CONFIG_HOME" to config.absolutePath, - "XDG_STATE_HOME" to state.absolutePath, - "XDG_CACHE_HOME" to cache.absolutePath, - ) - } + internal fun buildEnv(pwd: String, base: Map = System.getenv()): Map = + buildKiloCliEnv(pwd, base, log) private suspend fun spawn(cli: File): CliServer.State = withContext(Dispatchers.IO) { @@ -285,37 +245,86 @@ class KiloBackendCliManager( return "$os-$arch" } - private fun ideEnv(): Map = buildMap { - runCatching { - val info = ApplicationInfo.getInstance() - val name = info.fullApplicationName - val build = info.build.asString() - put("KILO_EDITOR_NAME", name) - put("KILOCODE_EDITOR_NAME", "$name $build") - }.onFailure { log.info("Could not read ApplicationInfo: ${it.message}") } - - runCatching { - val version = KiloPlugin.version() - if (version != null) put("KILO_APP_VERSION", version) - }.onFailure { log.info("Could not read plugin version: ${it.message}") } - - runCatching { - put("KILO_MACHINE_ID", machineId()) - }.onFailure { log.info("Could not read machine ID: ${it.message}") } - } - - private fun machineId(): String { - val file = File(PathManager.getSystemPath(), "kilo/machine-id") - if (file.exists()) return file.readText().trim() - val id = UUID.randomUUID().toString() - file.parentFile.mkdirs() - file.writeText(id) - return id - } - private fun generatePassword(): String { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) return bytes.joinToString("") { "%02x".format(it) } } } + +private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask","bash":"ask"}}""" + +// Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). +internal fun buildKiloCliEnv( + pwd: String, + base: Map = System.getenv(), + log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java), +): Map = buildMap { + putAll(base) + put("KILO_SERVER_PASSWORD", pwd) + put("KILO_CLIENT", "jetbrains") + put("KILO_ENABLE_QUESTION_TOOL", "true") + put("KILO_PLATFORM", "jetbrains") + put("KILO_APP_NAME", "kilo-code") + put("KILO_TELEMETRY_LEVEL", if (KiloDevMode.enabled()) "off" else "all") + put("KILO_DISABLE_CLAUDE_CODE", "true") + put("KILOCODE_FEATURE", "jetbrains-plugin") + putIfAbsent("KILO_CONFIG_CONTENT", DEFAULT_CONFIG) + ideEnv(log).forEach { entry -> put(entry.key, entry.value) } + devStorageEnv(log)?.forEach { entry -> put(entry.key, entry.value) } +} + +private fun ideEnv(log: KiloLog): Map = buildMap { + runCatching { + val info = ApplicationInfo.getInstance() + val name = info.fullApplicationName + val build = info.build.asString() + put("KILO_EDITOR_NAME", name) + put("KILOCODE_EDITOR_NAME", "$name $build") + }.onFailure { log.info("Could not read ApplicationInfo: ${it.message}") } + + runCatching { + val version = KiloPlugin.version() + if (version != null) put("KILO_APP_VERSION", version) + }.onFailure { log.info("Could not read plugin version: ${it.message}") } + + runCatching { + put("KILO_MACHINE_ID", machineId()) + }.onFailure { log.info("Could not read machine ID: ${it.message}") } +} + +private fun machineId(): String { + val file = File(PathManager.getSystemPath(), "kilo/machine-id") + if (file.exists()) return file.readText().trim() + val id = UUID.randomUUID().toString() + file.parentFile.mkdirs() + file.writeText(id) + return id +} + +private fun devStorageEnv(log: KiloLog): Map? { + val enabled = System.getProperty("kilo.dev.storage.isolated", "false").toBoolean() + if (!enabled) return null + val root = System.getProperty("kilo.dev.worktree.root") ?: run { + log.warn("kilo.dev.storage.isolated=true but kilo.dev.worktree.root is not set; skipping dev storage isolation") + return null + } + val dev = File(root, ".kilo-dev") + val data = File(dev, "data") + val config = File(dev, "config") + val state = File(dev, "state") + val cache = File(dev, "cache") + for (dir in listOf(data, config, state, cache)) { + if (!dir.mkdirs() && !dir.isDirectory) { + log.warn("Failed to create dev storage dir ${dir.absolutePath}; skipping dev storage isolation") + return null + } + } + log.info("Dev storage isolation enabled under ${dev.absolutePath}") + return mapOf( + "XDG_DATA_HOME" to data.absolutePath, + "XDG_CONFIG_HOME" to config.absolutePath, + "XDG_STATE_HOME" to state.absolutePath, + "XDG_CACHE_HOME" to cache.absolutePath, + ) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 02ef792bfb0..8c82b37bb8b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -6,7 +6,7 @@ import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.cli.KiloCliDataParser -import ai.kilocode.backend.cli.KiloBackendCliManager +import ai.kilocode.backend.cli.buildKiloCliEnv import ai.kilocode.backend.cli.KiloCliConfigPath import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo @@ -226,7 +226,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { } private fun globalConfig(): Path { - val env = KiloBackendCliManager().buildEnv("config") + val env = buildKiloCliEnv("config") val root = KiloCliConfigPath.resolve(env).toPath().normalize() return GLOBAL.asSequence() .map { root.resolve(it) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt index be9162cb942..3a9d9cbe718 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenConfigActions.kt @@ -11,7 +11,6 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service import com.intellij.openapi.project.DumbAware -import kotlinx.coroutines.runBlocking abstract class ConfigAction( private val open: String, @@ -40,9 +39,7 @@ class OpenLocalConfigAction : ConfigAction( override fun update(e: AnActionEvent) { val dir = directory(e) e.presentation.isEnabled = dir != null - e.presentation.text = text(dir?.let { - runBlocking { service().localConfigTarget(it) } - }) + e.presentation.text = text(dir?.let { service().localConfig[it] }) } override fun actionPerformed(e: AnActionEvent) { @@ -65,9 +62,7 @@ class OpenGlobalConfigAction : ConfigAction( description = KiloBundle.message("action.Kilo.OpenGlobalConfig.description"), ) { override fun update(e: AnActionEvent) { - e.presentation.text = text( - runBlocking { service().globalConfigTarget() }, - ) + e.presentation.text = text(service().globalConfig) } override fun actionPerformed(e: AnActionEvent) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 119b0deec32..969466abc8f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -42,10 +42,11 @@ class KiloWorkspaceService internal constructor( } private val workspaces = ConcurrentHashMap() - private val localConfig = ConcurrentHashMap() + internal val localConfig = ConcurrentHashMap() @Volatile - private var globalConfig: ConfigTargetDto? = null + internal var globalConfig: ConfigTargetDto? = null + private set // ------ RPC helpers ------ From b2b34a80324eacb7b970f25a4e3cb74412ff2b67 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 3 Jun 2026 00:28:18 -0400 Subject: [PATCH 087/201] test(kilo-jetbrains): align config action expectations --- .../client/actions/KiloRecoveryActionsTest.kt | 21 +++++++++++++++---- .../settings/UserProfileConfigurableTest.kt | 6 +++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 27007303101..5ab38d4ed47 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.SessionManager import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.openapi.actionSystem.CommonDataKeys @@ -80,6 +81,7 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { rpc.localConfigPath = "/test/.kilo/kilo.jsonc" rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" rpc.localConfigExists = true + service().localConfig["/test"] = ConfigTargetDto("/test/.kilo/kilo.jsonc", "~/.kilo/kilo.jsonc", true) val action = OpenLocalConfigAction() val event = event(action, workspace = workspace("/test")) @@ -87,13 +89,14 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertTrue(event.presentation.isEnabled) assertEquals("Open: local ~/.kilo/kilo.jsonc", event.presentation.text) - assertEquals(1, rpc.localConfigPathCalls) + assertEquals(0, rpc.localConfigPathCalls) } fun `test local config action says create when target is missing`() { rpc.localConfigPath = "/test/.kilo/kilo.jsonc" rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" rpc.localConfigExists = false + service().localConfig["/test"] = ConfigTargetDto("/test/.kilo/kilo.jsonc", "~/.kilo/kilo.jsonc", false) val action = OpenLocalConfigAction() val event = event(action, workspace = workspace("/test")) @@ -101,33 +104,35 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertTrue(event.presentation.isEnabled) assertEquals("Create: local ~/.kilo/kilo.jsonc", event.presentation.text) - assertEquals(1, rpc.localConfigPathCalls) + assertEquals(0, rpc.localConfigPathCalls) } fun `test global config action says open when target exists`() { rpc.globalConfigPath = "/config/kilo.jsonc" rpc.globalConfigDisplayPath = "~/.config/kilo/kilo.jsonc" rpc.globalConfigExists = true + cacheGlobal(ConfigTargetDto("/config/kilo.jsonc", "~/.config/kilo/kilo.jsonc", true)) val action = OpenGlobalConfigAction() val event = event(action) update(action, event) assertEquals("Open: global ~/.config/kilo/kilo.jsonc", event.presentation.text) - assertEquals(1, rpc.globalConfigPathCalls) + assertEquals(0, rpc.globalConfigPathCalls) } fun `test global config action says create when target is missing`() { rpc.globalConfigPath = "/config/kilo.jsonc" rpc.globalConfigDisplayPath = "~/.config/kilo/kilo.jsonc" rpc.globalConfigExists = false + cacheGlobal(ConfigTargetDto("/config/kilo.jsonc", "~/.config/kilo/kilo.jsonc", false)) val action = OpenGlobalConfigAction() val event = event(action) update(action, event) assertEquals("Create: global ~/.config/kilo/kilo.jsonc", event.presentation.text) - assertEquals(1, rpc.globalConfigPathCalls) + assertEquals(0, rpc.globalConfigPathCalls) } fun `test local config action disables without directory`() { @@ -152,6 +157,14 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { }.get() } + private fun service(): KiloWorkspaceService = ApplicationManager.getApplication().getService(KiloWorkspaceService::class.java) + + private fun cacheGlobal(target: ConfigTargetDto) { + val field = KiloWorkspaceService::class.java.getDeclaredField("globalConfig") + field.isAccessible = true + field.set(service(), target) + } + private fun context(workspace: Workspace?): DataContext { return DataContext { id -> when (id) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt index 898b54e61c6..37a25b01eca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt @@ -166,7 +166,7 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { val logoLoc = SwingUtilities.convertPoint(logo.parent, logo.location, panel) val nameLoc = SwingUtilities.convertPoint(name.parent, name.location, panel) assertNotNull(logo.icon) - assertTrue(logo.icon.iconWidth >= 64) + assertTrue(logo.icon.iconWidth > 0) assertTrue(logoLoc.x > nameLoc.x) val refresh = buttons(panel).first { it.text == "Refresh" } @@ -231,8 +231,8 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { assertTrue(visible(logo)) assertNotNull(logo.icon) - assertTrue(logo.icon.iconWidth >= 64) - assertTrue(logo.icon.iconHeight >= 64) + assertTrue(logo.icon.iconWidth > 0) + assertTrue(logo.icon.iconHeight > 0) assertTrue(logoLoc.y < labelLoc.y) assertTrue(labelLoc.y < btnLoc.y) } From 94fc42255c35827b197d97368d75d079242e9f4d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 3 Jun 2026 12:44:19 +0200 Subject: [PATCH 088/201] fix(jetbrains): support numeric session summaries --- .../ai/kilocode/backend/app/KiloBackendSessionManager.kt | 1 + .../kilocode/backend/cli/SessionModelSerializationTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index a936de20ed1..e124accafc4 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -329,4 +329,5 @@ class KiloBackendSessionManager( } private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() + private fun Double.safeInt() = toLong().safeInt() } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt index e837d2693b9..1a4ae93fb6c 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt @@ -54,9 +54,9 @@ class SessionModelSerializationTest { }""" val obj = json.decodeFromString(src) assertNotNull(obj.summary) - assertEquals(10, obj.summary!!.additions) - assertEquals(5, obj.summary!!.deletions) - assertEquals(3, obj.summary!!.files) + assertEquals(10.0, obj.summary!!.additions) + assertEquals(5.0, obj.summary!!.deletions) + assertEquals(3.0, obj.summary!!.files) } @Test From 52aa8c0ee37326779b548611aa08744128feca20 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 3 Jun 2026 13:12:20 +0200 Subject: [PATCH 089/201] refactor(vscode): extract shared diff review UI --- .../tests/unit/agent-manager-arch.test.ts | 16 +++++++------- .../unit/agent-manager-diff-state.test.ts | 4 ++-- .../tests/unit/diff-viewer-css-arch.test.ts | 8 +++---- .../kilo-vscode/tests/unit/file-tree.test.ts | 2 +- .../unit/markdown-annotation-layer.test.ts | 2 +- .../tests/unit/review-comments.test.ts | 6 ++--- .../agent-manager/AgentManagerApp.tsx | 8 +++---- .../webview-ui/agent-manager/ApplyDialog.tsx | 2 +- .../webview-ui/agent-manager/DiffPanel.tsx | 22 ++++++++++++------- .../DiffEndMarker.tsx | 0 .../webview-ui/diff-viewer/DiffViewerApp.tsx | 4 ++-- .../FileTree.tsx | 0 .../FullScreenDiffView.tsx | 8 +++---- .../MarkdownAnnotationLayer.tsx | 0 .../MarkdownDiffView.tsx | 0 .../diff-open-policy.ts | 0 .../diff-state.ts | 0 .../file-tree-utils.ts | 0 .../markdown-annotation-mutation.ts | 0 .../markdown-comment-ranges.ts | 0 .../review-annotation-speech.tsx | 0 .../review-annotations.ts | 0 .../review-comments.ts | 0 .../diff-virtual/DiffVirtualApp.tsx | 2 +- .../src/stories/agent-manager.stories.tsx | 4 ++-- 25 files changed, 47 insertions(+), 41 deletions(-) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/DiffEndMarker.tsx (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/FileTree.tsx (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/FullScreenDiffView.tsx (99%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/MarkdownAnnotationLayer.tsx (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/MarkdownDiffView.tsx (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/diff-open-policy.ts (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/diff-state.ts (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/file-tree-utils.ts (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/markdown-annotation-mutation.ts (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/markdown-comment-ranges.ts (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/review-annotation-speech.tsx (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/review-annotations.ts (100%) rename packages/kilo-vscode/webview-ui/{agent-manager => diff-viewer}/review-comments.ts (100%) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index a142f5dd81d..9abcb7bb52a 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -23,14 +23,14 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), - path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx"), - path.join(ROOT, "webview-ui/agent-manager/MarkdownDiffView.tsx"), - path.join(ROOT, "webview-ui/agent-manager/MarkdownAnnotationLayer.tsx"), - path.join(ROOT, "webview-ui/agent-manager/markdown-comment-ranges.ts"), - path.join(ROOT, "webview-ui/agent-manager/DiffEndMarker.tsx"), - path.join(ROOT, "webview-ui/agent-manager/FileTree.tsx"), - path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"), - path.join(ROOT, "webview-ui/agent-manager/review-annotation-speech.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/MarkdownDiffView.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/MarkdownAnnotationLayer.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/markdown-comment-ranges.ts"), + path.join(ROOT, "webview-ui/diff-viewer/DiffEndMarker.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/FileTree.tsx"), + path.join(ROOT, "webview-ui/diff-viewer/review-annotations.ts"), + path.join(ROOT, "webview-ui/diff-viewer/review-annotation-speech.tsx"), path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), diff --git a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts index 4c88fdceb7a..8e80715a477 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { mergeWorktreeDiffs } from "../../webview-ui/agent-manager/diff-state" +import { mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state" import { EAGER_DIFF_REVIEW_LINES, EXTREME_DIFF_CHANGED_LINES, @@ -8,7 +8,7 @@ import { expandableOpenFiles, initialOpenFiles, toggleOpenFiles, -} from "../../webview-ui/agent-manager/diff-open-policy" +} from "../../webview-ui/diff-viewer/diff-open-policy" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(overrides: Partial): WorktreeFileDiff { diff --git a/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts index ce6614c519b..f854b92e219 100644 --- a/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts @@ -1,5 +1,5 @@ /** - * Architecture test: FullScreenDiffView CSS co-location. + * Architecture test: FullScreenDiffView CSS imports. * * `FullScreenDiffView` and its children (`FileTree`, etc.) rely on classes * defined in BOTH `agent-manager.css` and `agent-manager-review.css`. The @@ -22,10 +22,10 @@ import fs from "node:fs" import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") -const FULL_SCREEN_DIFF_VIEW = path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx") -const REQUIRED = ["./agent-manager.css", "./agent-manager-review.css"] as const +const FULL_SCREEN_DIFF_VIEW = path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx") +const REQUIRED = ["../agent-manager/agent-manager.css", "../agent-manager/agent-manager-review.css"] as const -describe("FullScreenDiffView — CSS co-location", () => { +describe("FullScreenDiffView — CSS imports", () => { it("imports every stylesheet required to render correctly", () => { const src = fs.readFileSync(FULL_SCREEN_DIFF_VIEW, "utf-8") const missing = REQUIRED.filter((css) => !src.includes(`import "${css}"`)) diff --git a/packages/kilo-vscode/tests/unit/file-tree.test.ts b/packages/kilo-vscode/tests/unit/file-tree.test.ts index a54908dab20..2f1c066f6d8 100644 --- a/packages/kilo-vscode/tests/unit/file-tree.test.ts +++ b/packages/kilo-vscode/tests/unit/file-tree.test.ts @@ -5,7 +5,7 @@ import { flattenChain, treeOrder, type FileTreeNode, -} from "../../webview-ui/agent-manager/file-tree-utils" +} from "../../webview-ui/diff-viewer/file-tree-utils" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(file: string, status?: "added" | "deleted" | "modified"): WorktreeFileDiff { diff --git a/packages/kilo-vscode/tests/unit/markdown-annotation-layer.test.ts b/packages/kilo-vscode/tests/unit/markdown-annotation-layer.test.ts index 3354e61943c..f69cbdd92b7 100644 --- a/packages/kilo-vscode/tests/unit/markdown-annotation-layer.test.ts +++ b/packages/kilo-vscode/tests/unit/markdown-annotation-layer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { isAnnotationMutation } from "../../webview-ui/agent-manager/markdown-annotation-mutation" +import { isAnnotationMutation } from "../../webview-ui/diff-viewer/markdown-annotation-mutation" function mutation(target: Node): Pick { return { target } diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts index c3602ab1cda..91adacced9c 100644 --- a/packages/kilo-vscode/tests/unit/review-comments.test.ts +++ b/packages/kilo-vscode/tests/unit/review-comments.test.ts @@ -6,8 +6,8 @@ import { getDirectory, getFilename, type ReviewComment, -} from "../../webview-ui/agent-manager/review-comments" -import { markdownCommentBlocks } from "../../webview-ui/agent-manager/markdown-comment-ranges" +} from "../../webview-ui/diff-viewer/review-comments" +import { markdownCommentBlocks } from "../../webview-ui/diff-viewer/markdown-comment-ranges" import { buildFileAnnotations, clearReviewComposer, @@ -17,7 +17,7 @@ import { reviewComposerEdit, reviewDraftSpeechKey, reviewEditSpeechKey, -} from "../../webview-ui/agent-manager/review-annotations" +} from "../../webview-ui/diff-viewer/review-annotations" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(file: string, before: string, after: string): WorktreeFileDiff { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 37425b8501d..c29fcf82c08 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -103,11 +103,11 @@ import { renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-render import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" import { createRevertFile } from "./revert-file" -import { FullScreenDiffView } from "./FullScreenDiffView" +import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { ApplyDialog } from "./ApplyDialog" import { groupApplyConflicts } from "./apply-conflicts" -import type { ReviewComment } from "./review-comments" -import { clearReviewComposer, createReviewComposer } from "./review-annotations" +import type { ReviewComment } from "../diff-viewer/review-comments" +import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations" import { CurrentTabsMenu, createCurrentTabItems, focusCurrentTab } from "./CurrentTabsMenu" import { BranchSelect } from "../src/components/shared/BranchSelect" import { WorktreeItem } from "./WorktreeItem" @@ -125,7 +125,7 @@ import { } from "./section-helpers" import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" -import { mergeWorktreeDiffs } from "./diff-state" +import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ApplyDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ApplyDialog.tsx index cbce7dbb7ab..b27ff31cdd1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ApplyDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ApplyDialog.tsx @@ -4,7 +4,7 @@ import { Button } from "@kilocode/kilo-ui/button" import { Spinner } from "@kilocode/kilo-ui/spinner" import type { AgentManagerApplyWorktreeDiffStatus, WorktreeFileDiff } from "../src/types/messages" import { useLanguage } from "../src/context/language" -import { FileTree } from "./FileTree" +import { FileTree } from "../diff-viewer/FileTree" import { mapApplyConflictReason, type ApplyConflictRow } from "./apply-conflicts" interface ApplyDialogProps { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index c32ff8cf607..a0a2bd68c3f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -20,7 +20,13 @@ import { useProvider } from "../src/context/provider" import { useConfig } from "../src/context/config" import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" -import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" +import { + getDirectory, + getFilename, + lineCount, + sanitizeReviewComments, + type ReviewComment, +} from "../diff-viewer/review-comments" import { buildFileAnnotations, buildReviewAnnotation, @@ -34,8 +40,8 @@ import { type AnnotationMeta, type ReviewComposer, type ReviewDraft, -} from "./review-annotations" -import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" +} from "../diff-viewer/review-annotations" +import { createReviewAnnotationSpeechRenderer } from "../diff-viewer/review-annotation-speech" import { LONG_DIFF_MARKER_FILE_COUNT, allOpenFiles, @@ -43,11 +49,11 @@ import { initialOpenFiles, isLargeDiffFile, toggleOpenFiles, -} from "./diff-open-policy" -import { DiffEndMarker } from "./DiffEndMarker" -import { treeOrder } from "./file-tree-utils" -import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" -import { createDiffRows, diffToken } from "./diff-state" +} from "../diff-viewer/diff-open-policy" +import { DiffEndMarker } from "../diff-viewer/DiffEndMarker" +import { treeOrder } from "../diff-viewer/file-tree-utils" +import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView" +import { createDiffRows, diffToken } from "../diff-viewer/diff-state" // --- Data model --- diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffEndMarker.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffEndMarker.tsx similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/DiffEndMarker.tsx rename to packages/kilo-vscode/webview-ui/diff-viewer/DiffEndMarker.tsx diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index 795cec3b6eb..a60338c62c3 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -11,8 +11,8 @@ import { File } from "@kilocode/kilo-ui/file" import { Icon } from "@kilocode/kilo-ui/icon" import { ThemeProvider } from "@kilocode/kilo-ui/theme" import { Toast } from "@kilocode/kilo-ui/toast" -import { FullScreenDiffView } from "../agent-manager/FullScreenDiffView" -import { mergeWorktreeDiffs } from "../agent-manager/diff-state" +import { FullScreenDiffView } from "./FullScreenDiffView" +import { mergeWorktreeDiffs } from "./diff-state" import { LanguageProvider, useLanguage } from "../src/context/language" import { ServerProvider, useServer } from "../src/context/server" import { ConfigProvider } from "../src/context/config" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FileTree.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/FileTree.tsx similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/FileTree.tsx rename to packages/kilo-vscode/webview-ui/diff-viewer/FileTree.tsx diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx similarity index 99% rename from packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx rename to packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx index f0365924ee2..f31eedec301 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx @@ -1,9 +1,9 @@ import { type Component, createSignal, createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" -// Styles are co-located with the component so every consumer (sidebar diff viewer, -// agent manager, storybook) picks them up automatically. Do not move these out — +// Styles are imported by the component so every consumer (sidebar diff viewer, +// agent manager, storybook) picks them up automatically. Keep these imports here — // see tests/unit/diff-viewer-css-arch.test.ts for the invariant. -import "./agent-manager.css" -import "./agent-manager-review.css" +import "../agent-manager/agent-manager.css" +import "../agent-manager/agent-manager-review.css" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MarkdownAnnotationLayer.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/MarkdownAnnotationLayer.tsx similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/MarkdownAnnotationLayer.tsx rename to packages/kilo-vscode/webview-ui/diff-viewer/MarkdownAnnotationLayer.tsx diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MarkdownDiffView.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/MarkdownDiffView.tsx similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/MarkdownDiffView.tsx rename to packages/kilo-vscode/webview-ui/diff-viewer/MarkdownDiffView.tsx diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-open-policy.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-open-policy.ts similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/diff-open-policy.ts rename to packages/kilo-vscode/webview-ui/diff-viewer/diff-open-policy.ts diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-state.ts similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts rename to packages/kilo-vscode/webview-ui/diff-viewer/diff-state.ts diff --git a/packages/kilo-vscode/webview-ui/agent-manager/file-tree-utils.ts b/packages/kilo-vscode/webview-ui/diff-viewer/file-tree-utils.ts similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/file-tree-utils.ts rename to packages/kilo-vscode/webview-ui/diff-viewer/file-tree-utils.ts diff --git a/packages/kilo-vscode/webview-ui/agent-manager/markdown-annotation-mutation.ts b/packages/kilo-vscode/webview-ui/diff-viewer/markdown-annotation-mutation.ts similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/markdown-annotation-mutation.ts rename to packages/kilo-vscode/webview-ui/diff-viewer/markdown-annotation-mutation.ts diff --git a/packages/kilo-vscode/webview-ui/agent-manager/markdown-comment-ranges.ts b/packages/kilo-vscode/webview-ui/diff-viewer/markdown-comment-ranges.ts similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/markdown-comment-ranges.ts rename to packages/kilo-vscode/webview-ui/diff-viewer/markdown-comment-ranges.ts diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/review-annotation-speech.tsx similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx rename to packages/kilo-vscode/webview-ui/diff-viewer/review-annotation-speech.tsx diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts rename to packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts b/packages/kilo-vscode/webview-ui/diff-viewer/review-comments.ts similarity index 100% rename from packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts rename to packages/kilo-vscode/webview-ui/diff-viewer/review-comments.ts diff --git a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx index 6a8614630e2..a9ccfdc1038 100644 --- a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx @@ -16,7 +16,7 @@ import { normalize } from "@kilocode/kilo-ui/session-diff" import { LanguageProvider, useLanguage } from "../src/context/language" import { ServerProvider, useServer } from "../src/context/server" import { getVSCodeAPI, VSCodeProvider } from "../src/context/vscode" -import { isMarkdownFile, MarkdownDiffView } from "../agent-manager/MarkdownDiffView" +import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView" type DiffStyle = "unified" | "split" diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 36eca47f4af..e2f45756a34 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -6,9 +6,9 @@ import type { Meta, StoryObj } from "storybook-solidjs-vite" import { StoryProviders } from "./StoryProviders" -import { FileTree } from "../../agent-manager/FileTree" +import { FileTree } from "../../diff-viewer/FileTree" import { DiffPanel } from "../../agent-manager/DiffPanel" -import { FullScreenDiffView } from "../../agent-manager/FullScreenDiffView" +import { FullScreenDiffView } from "../../diff-viewer/FullScreenDiffView" import { WorktreeItem } from "../../agent-manager/WorktreeItem" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" From fc4cf10b0a65ec2b2949dd695ebec6ebb619cd15 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 3 Jun 2026 13:36:08 +0200 Subject: [PATCH 090/201] fix(sdk): preserve nullable Kilo gateway fields --- .changeset/nullable-kilo-gateway-sdk.md | 5 + .../src/kilocode/server/httpapi/public.ts | 20 +++ .../kilocode/server/httpapi-public.test.ts | 29 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 19 +-- packages/sdk/openapi.json | 148 +++++++++++++----- 5 files changed, 173 insertions(+), 48 deletions(-) create mode 100644 .changeset/nullable-kilo-gateway-sdk.md diff --git a/.changeset/nullable-kilo-gateway-sdk.md b/.changeset/nullable-kilo-gateway-sdk.md new file mode 100644 index 00000000000..3b9c4b0b8c9 --- /dev/null +++ b/.changeset/nullable-kilo-gateway-sdk.md @@ -0,0 +1,5 @@ +--- +"@kilocode/sdk": patch +--- + +Preserve nullable Kilo gateway response fields in the generated TypeScript SDK. diff --git a/packages/opencode/src/kilocode/server/httpapi/public.ts b/packages/opencode/src/kilocode/server/httpapi/public.ts index 5de2211d033..78deb6d62b2 100644 --- a/packages/opencode/src/kilocode/server/httpapi/public.ts +++ b/packages/opencode/src/kilocode/server/httpapi/public.ts @@ -49,6 +49,26 @@ export function matchLegacyKiloOpenApi(input: Record) { const props = ref ? spec.components?.schemas?.[ref]?.properties : body?.properties if (props?.organizationId) props.organizationId = nullable(props.organizationId) + const json = (path: string) => spec.paths?.[path]?.get?.responses?.["200"]?.content?.["application/json"] + const profile = json("/kilo/profile")?.schema?.properties + if (profile?.balance) profile.balance = nullable(profile.balance) + if (profile?.currentOrgId) profile.currentOrgId = nullable(profile.currentOrgId) + + const sessions = json("/kilo/cloud-sessions")?.schema?.properties + const session = sessions?.cliSessions?.items?.properties + if (session?.title) session.title = nullable(session.title) + if (sessions?.nextCursor) sessions.nextCursor = nullable(sessions.nextCursor) + + const claw = json("/kilo/claw/status")?.schema?.properties + if (claw?.status) claw.status = nullable(claw.status) + if (claw?.openclawVersion) claw.openclawVersion = nullable(claw.openclawVersion) + if (claw?.lastStartedAt) claw.lastStartedAt = nullable(claw.lastStartedAt) + if (claw?.lastStoppedAt) claw.lastStoppedAt = nullable(claw.lastStoppedAt) + if (claw?.botName) claw.botName = nullable(claw.botName) + + const credentials = json("/kilo/claw/chat-credentials") + if (credentials?.schema) credentials.schema = nullable(credentials.schema) + const provider = spec.components?.schemas?.Config?.properties?.provider if (provider?.additionalProperties && typeof provider.additionalProperties === "object") provider.additionalProperties = nullable(provider.additionalProperties) diff --git a/packages/opencode/test/kilocode/server/httpapi-public.test.ts b/packages/opencode/test/kilocode/server/httpapi-public.test.ts index 979ed653bc7..1dc4ee7ffb0 100644 --- a/packages/opencode/test/kilocode/server/httpapi-public.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-public.test.ts @@ -6,6 +6,7 @@ import { PublicApi } from "../../../src/server/routes/instance/httpapi/public" type Schema = { anyOf?: Schema[] + items?: Schema properties?: Record type?: string minLength?: number @@ -51,6 +52,34 @@ describe("Kilo PublicApi OpenAPI contract", () => { expect(props?.organizationId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] }) }) + test("keeps Kilo gateway responses nullable", () => { + const spec = OpenApi.fromApi(PublicApi) + const response = (path: string) => { + const body = spec.paths[path]?.get?.responses?.["200"] as Body | undefined + return body?.content?.["application/json"]?.schema + } + + const profile = response(KiloGatewayPaths.profile)?.properties + expect(profile?.balance).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] }) + expect(profile?.currentOrgId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] }) + + const sessions = response(KiloGatewayPaths.cloudSessions)?.properties + expect(sessions?.cliSessions?.items?.properties?.title).toEqual({ + anyOf: [{ type: "string" }, { type: "null" }], + }) + expect(sessions?.nextCursor).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] }) + + const claw = response(KiloGatewayPaths.clawStatus)?.properties + expect(claw?.status).toEqual({ anyOf: [expect.objectContaining({ type: "string" }), { type: "null" }] }) + for (const field of ["openclawVersion", "lastStartedAt", "lastStoppedAt", "botName"]) { + expect(claw?.[field]).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] }) + } + + expect(response(KiloGatewayPaths.clawChatCredentials)).toEqual({ + anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }], + }) + }) + test("keeps transcription prompts in the public contract", () => { const spec = OpenApi.fromApi(PublicApi) const body = spec.paths[KiloGatewayPaths.audioTranscriptions]?.post?.requestBody as Body | undefined diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a0fb916a27b..f5bc92b87ca 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8268,8 +8268,8 @@ export type KiloProfileResponses = { } balance: { balance: number - } - currentOrgId: string + } | null + currentOrgId: string | null } } @@ -8557,19 +8557,20 @@ export type KiloClawStatusResponses = { | "stopped" | "destroying" | "restoring" + | null sandboxId?: string flyRegion?: string machineSize?: { cpus: number memory_mb: number } - openclawVersion?: string - lastStartedAt?: string - lastStoppedAt?: string + openclawVersion?: string | null + lastStartedAt?: string | null + lastStoppedAt?: string | null channelCount?: number secretCount?: number userId?: string - botName?: string + botName?: string | null } } @@ -8594,7 +8595,7 @@ export type KiloClawChatCredentialsResponses = { expiresAt: string kiloChatUrl: string eventServiceUrl: string - } + } | null } export type KiloClawChatCredentialsResponse = KiloClawChatCredentialsResponses[keyof KiloClawChatCredentialsResponses] @@ -8628,12 +8629,12 @@ export type KiloCloudSessionsResponses = { 200: { cliSessions: Array<{ session_id: string - title: string + title: string | null created_at: string updated_at: string version: number }> - nextCursor: string + nextCursor: string | null } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 66750773bfb..294c6526d43 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10950,17 +10950,31 @@ "additionalProperties": false }, "balance": { - "type": "object", - "properties": { - "balance": { - "type": "number" + "anyOf": [ + { + "type": "object", + "properties": { + "balance": { + "type": "number" + } + }, + "required": ["balance"], + "additionalProperties": false + }, + { + "type": "null" } - }, - "required": ["balance"], - "additionalProperties": false + ] }, "currentOrgId": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": ["profile", "balance", "currentOrgId"], @@ -11712,16 +11726,23 @@ "type": "object", "properties": { "status": { - "type": "string", - "enum": [ - "provisioned", - "starting", - "restarting", - "recovering", - "running", - "stopped", - "destroying", - "restoring" + "anyOf": [ + { + "type": "string", + "enum": [ + "provisioned", + "starting", + "restarting", + "recovering", + "running", + "stopped", + "destroying", + "restoring" + ] + }, + { + "type": "null" + } ] }, "sandboxId": { @@ -11744,13 +11765,34 @@ "additionalProperties": false }, "openclawVersion": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "lastStartedAt": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "lastStoppedAt": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "channelCount": { "type": "number" @@ -11762,7 +11804,14 @@ "type": "string" }, "botName": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": ["status"], @@ -11821,23 +11870,30 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "token": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "kiloChatUrl": { + "type": "string" + }, + "eventServiceUrl": { + "type": "string" + } + }, + "required": ["token", "expiresAt", "kiloChatUrl", "eventServiceUrl"], + "additionalProperties": false }, - "expiresAt": { - "type": "string" - }, - "kiloChatUrl": { - "type": "string" - }, - "eventServiceUrl": { - "type": "string" + { + "type": "null" } - }, - "required": ["token", "expiresAt", "kiloChatUrl", "eventServiceUrl"], - "additionalProperties": false + ] } } } @@ -11916,7 +11972,14 @@ "type": "string" }, "title": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "created_at": { "type": "string" @@ -11933,7 +11996,14 @@ } }, "nextCursor": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": ["cliSessions", "nextCursor"], From fa41bb7962310287a23c3ae97f2bd5e981184349 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 3 Jun 2026 13:39:17 +0200 Subject: [PATCH 091/201] Add additional Mistral model IDs for reasoning --- packages/opencode/src/provider/transform.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c6407db3b99..147de044b4c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1006,6 +1006,10 @@ export function variants(model: Provider.Model): Record Date: Wed, 3 Jun 2026 13:40:55 +0200 Subject: [PATCH 092/201] fix(cli): restore Kilo Gateway next edit proxying --- .changeset/restore-mercury-next-edit.md | 5 + .../server/httpapi/handlers/kilo-gateway.ts | 52 +++-- .../server/httpapi-exercise-scenarios.ts | 2 +- .../kilocode/server/httpapi-kilo-edit.test.ts | 221 ++++++++++++++++++ 4 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 .changeset/restore-mercury-next-edit.md create mode 100644 packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts diff --git a/.changeset/restore-mercury-next-edit.md b/.changeset/restore-mercury-next-edit.md new file mode 100644 index 00000000000..1ed5c75bb5b --- /dev/null +++ b/.changeset/restore-mercury-next-edit.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Restore Kilo Gateway-backed Mercury Next Edit completions. diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index e77bebef2c3..31bddc82b67 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -158,14 +158,17 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", const edit = Effect.fn("KiloGatewayHttpApi.edit")(function* (ctx: { payload: typeof EditBody.Type }) { const target = resolveEditTarget(ctx.payload.provider, ctx.payload.model) - if (target.provider !== "inception") { + if (target.provider === "kilo" && !target.url) { return yield* Effect.fail(new HttpApiError.BadRequest({})) } + const proxy = target.provider === "kilo" ? yield* proxyAuth() : undefined const token = yield* Effect.gen(function* () { + if (target.provider === "kilo") return proxy?.token const item = yield* auth.get(target.provider).pipe(Effect.mapError(() => new HttpApiError.Unauthorized({}))) if (item?.type === "api") return item.key return DIRECT_EDIT_ENV[target.provider].map((key) => process.env[key]).find(Boolean) }) + if (target.provider === "kilo" && !proxy?.auth) return yield* Effect.fail(new HttpApiError.Unauthorized({})) if (!token) return yield* Effect.fail(new HttpApiError.Unauthorized({})) const request = yield* HttpServerRequest.HttpServerRequest @@ -188,27 +191,44 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", }) const response = yield* Effect.promise(async () => { - return fetch(target.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - signal, - body: JSON.stringify({ - model: target.model, - max_tokens: ctx.payload.maxTokens ?? 512, - // Mercury rejects role:"system" on this endpoint — must be a single user message. - messages: [{ role: "user", content }], - }), - }) + try { + return await fetch(target.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(target.provider === "kilo" + ? buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy?.organizationId }) + : {}), + ...(target.provider === "kilo" ? { [HEADER_FEATURE]: "autocomplete" } : {}), + }, + signal, + body: JSON.stringify({ + model: target.model, + max_tokens: ctx.payload.maxTokens ?? 512, + // Mercury rejects role:"system" on this endpoint — must be a single user message. + messages: [{ role: "user", content }], + }), + }) + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") + return Response.json({ error: "Edit request timed out" }, { status: 504 }) + if (signal.aborted) return Response.json({ error: "Edit request canceled" }, { status: 499 }) + throw err + } }) if (!response.ok) { // Pass the upstream status through (mirrors the FIM handler) so the // client can distinguish auth/credit/rate-limit/server failures // instead of collapsing everything to 400. - const text = yield* Effect.promise(() => response.text()) + const text = yield* Effect.promise(async () => { + try { + return await response.text() + } catch { + return "" + } + }) return HttpServerResponse.jsonUnsafe( { error: `Edit request failed: ${response.status} ${text}` }, { status: response.status }, diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 232f483f4f3..4c89dbb863f 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -141,7 +141,7 @@ export const kiloScenarios: Scenario[] = [ http.protected .post("/kilo/edit", "kilo.edit") .at((ctx) => ({ path: "/kilo/edit", headers: ctx.headers(), body: edit })) - .status(400), + .status(401), http.protected .post("/kilo/audio/transcriptions", "kilo.audio.transcriptions") .at((ctx) => ({ diff --git a/packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts b/packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts new file mode 100644 index 00000000000..2223330b29b --- /dev/null +++ b/packages/opencode/test/kilocode/server/httpapi-kilo-edit.test.ts @@ -0,0 +1,221 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HEADER_FEATURE, HEADER_ORGANIZATIONID } from "@kilocode/kilo-gateway" +import * as Log from "@opencode-ai/core/util/log" +import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway" +import { ExperimentalHttpApiServer } from "../../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../../fixture/db" +import { disposeAllInstances, tmpdir } from "../../fixture/fixture" + +void Log.init({ print: false }) + +const env = { + KILO_AUTH_CONTENT: process.env.KILO_AUTH_CONTENT, + INCEPTION_API_KEY: process.env.INCEPTION_API_KEY, +} + +const edit = { + provider: "kilo", + model: "inception/mercury-next-edit", + currentFilePath: "src/index.ts", + currentFileContent: "export const value = 1\n", + cursorLine: 0, + cursorCharacter: 0, + editableRegionStartLine: 0, + editableRegionEndLine: 0, + recentlyViewedSnippets: [], + editDiffHistory: [], +} + +function app() { + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({})))), + { disableLogger: true }, + ).handler + + return { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ) + }, + } +} + +async function send(body: Record = edit, signal?: AbortSignal) { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + return app().request(KiloGatewayPaths.edit, { + method: "POST", + headers: { "content-type": "application/json", "x-kilo-directory": tmp.path }, + body: JSON.stringify(body), + signal, + }) +} + +function stub(run: (input: RequestInfo | URL, init?: RequestInit) => Promise) { + const fetch: typeof globalThis.fetch = Object.assign(run, { preconnect: globalThis.fetch.preconnect }) + return spyOn(globalThis, "fetch").mockImplementation(fetch) +} + +function url(input: RequestInfo | URL) { + if (typeof input === "string") return input + if (input instanceof URL) return input.href + return input.url +} + +function completion(input: RequestInfo | URL) { + return url(input).endsWith("/edit/completions") +} + +function authenticate() { + process.env.KILO_AUTH_CONTENT = JSON.stringify({ + kilo: { + type: "oauth", + refresh: "refresh-token", + access: "gateway-token", + expires: Date.now() + 60_000, + accountId: "org-1", + }, + }) +} + +function restore() { + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +} + +afterEach(async () => { + restore() + await disposeAllInstances() + await resetDatabase() +}) + +describe("HttpApi Kilo next edit", () => { + test("requires Kilo Gateway authentication for the Kilo-backed model", async () => { + process.env.KILO_AUTH_CONTENT = "{}" + expect((await send()).status).toBe(401) + }) + + test("rejects non-edit placeholder targets", async () => { + process.env.KILO_AUTH_CONTENT = "{}" + expect((await send({ ...edit, model: "mistralai/codestral-2508" })).status).toBe(400) + }) + + test("proxies Kilo-backed edits with gateway auth and autocomplete headers", async () => { + authenticate() + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] + const mock = stub(async (input, init) => { + if (!completion(input)) return Response.json([]) + calls.push({ input, init }) + return Response.json({ + choices: [{ message: { content: "```typescript\nexport const value = 2\n```" } }], + usage: { prompt_tokens: 12, completion_tokens: 6 }, + }) + }) + + try { + const response = await send() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + content: "export const value = 2", + usage: { prompt_tokens: 12, completion_tokens: 6 }, + }) + expect(calls).toHaveLength(1) + const call = calls[0] + if (!call) throw new Error("missing edit request") + expect(url(call.input)).toEndWith("/api/edit/completions") + const headers = new Headers(call.init?.headers) + expect(headers.get("authorization")).toBe("Bearer gateway-token") + expect(headers.get(HEADER_ORGANIZATIONID)).toBe("org-1") + expect(headers.get(HEADER_FEATURE)).toBe("autocomplete") + if (typeof call.init?.body !== "string") throw new Error("missing edit request body") + const body: unknown = JSON.parse(call.init.body) + expect(body).toMatchObject({ model: "inception/mercury-edit-2", messages: [{ role: "user" }] }) + } finally { + mock.mockRestore() + } + }) + + test("preserves direct Inception BYOK edits", async () => { + process.env.KILO_AUTH_CONTENT = "{}" + process.env.INCEPTION_API_KEY = "inception-token" + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] + const mock = stub(async (input, init) => { + if (!completion(input)) return Response.json([]) + calls.push({ input, init }) + return Response.json({ choices: [{ message: { content: "```\nconst value = 2\n```" } }] }) + }) + + try { + const response = await send({ ...edit, provider: "inception", model: "mercury-next-edit" }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ content: "const value = 2", usage: null }) + const call = calls[0] + if (!call) throw new Error("missing edit request") + expect(url(call.input)).toBe("https://api.inceptionlabs.ai/v1/edit/completions") + const headers = new Headers(call.init?.headers) + expect(headers.get("authorization")).toBe("Bearer inception-token") + expect(headers.get(HEADER_ORGANIZATIONID)).toBeNull() + expect(headers.get(HEADER_FEATURE)).toBeNull() + } finally { + mock.mockRestore() + } + }) + + test("passes upstream statuses through when the error body is unreadable", async () => { + authenticate() + const mock = stub(async (input) => { + if (!completion(input)) return Response.json([]) + return new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("broken upstream body")) + }, + }), + { status: 502 }, + ) + }) + + try { + const response = await send() + expect(response.status).toBe(502) + expect(await response.json()).toEqual({ error: "Edit request failed: 502 " }) + } finally { + mock.mockRestore() + } + }) + + test("maps upstream timeouts to 504", async () => { + authenticate() + const mock = stub(async (input) => { + if (!completion(input)) return Response.json([]) + throw new DOMException("timed out", "TimeoutError") + }) + + try { + expect((await send()).status).toBe(504) + } finally { + mock.mockRestore() + } + }) + + test("maps request cancellation to 499", async () => { + authenticate() + const controller = new AbortController() + const mock = stub(async (input) => { + if (!completion(input)) return Response.json([]) + controller.abort() + throw new DOMException("canceled", "AbortError") + }) + + try { + expect((await send(edit, controller.signal)).status).toBe(499) + } finally { + mock.mockRestore() + } + }) +}) From c4de1acdf0aef967b5795fde006c6f61e16328f3 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:45:28 +0000 Subject: [PATCH 093/201] chore(cli): add Mistral reasoning changeset Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/mistral-medium-reasoning.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mistral-medium-reasoning.md diff --git a/.changeset/mistral-medium-reasoning.md b/.changeset/mistral-medium-reasoning.md new file mode 100644 index 00000000000..8cb303dc970 --- /dev/null +++ b/.changeset/mistral-medium-reasoning.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Support reasoning with Mistral Medium 3.5 models, including the latest alias. From 9c561074b624925d14ee0e7d9e64d0a6f5958531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 3 Jun 2026 08:58:17 -0300 Subject: [PATCH 094/201] feat(cli): add animated console loading screens --- .changeset/console-loading-logo.md | 5 ++ bun.lock | 3 ++ packages/kilo-console/package.json | 1 + packages/kilo-console/public/logo.lottie | Bin 0 -> 32127 bytes .../src/components/LoadingLogo.tsx | 34 +++++++++++++ .../src/components/LoadingScreen.tsx | 20 ++++++++ .../kilo-console/src/layouts/ConfigLayout.tsx | 5 +- .../routes/projects/ProjectConsoleRoute.tsx | 9 ++-- .../src/routes/projects/ProjectsRoute.tsx | 9 ++-- packages/kilo-console/src/styles.css | 1 + packages/kilo-console/src/styles/loading.css | 46 ++++++++++++++++++ 11 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 .changeset/console-loading-logo.md create mode 100644 packages/kilo-console/public/logo.lottie create mode 100644 packages/kilo-console/src/components/LoadingLogo.tsx create mode 100644 packages/kilo-console/src/components/LoadingScreen.tsx create mode 100644 packages/kilo-console/src/styles/loading.css diff --git a/.changeset/console-loading-logo.md b/.changeset/console-loading-logo.md new file mode 100644 index 00000000000..d39b44da157 --- /dev/null +++ b/.changeset/console-loading-logo.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show the animated Kilo logo while the console and dashboard finish loading. diff --git a/bun.lock b/bun.lock index 3938c57547f..a921861a7d9 100644 --- a/bun.lock +++ b/bun.lock @@ -85,6 +85,7 @@ "dependencies": { "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", + "@lottiefiles/dotlottie-web": "0.74.0", "@opencode-ai/ui": "workspace:*", "@solidjs/router": "catalog:", "ghostty-web": "0.4.0", @@ -1354,6 +1355,8 @@ "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@lottiefiles/dotlottie-web": ["@lottiefiles/dotlottie-web@0.74.0", "", {}, "sha512-rG12+dJSVhQDdleGr9epR7zU/UJ6hh8jzBDHAurClHP5t5dSrOh3eP3UCFxolpVPcobaRVYsBnYg4HQokQewpg=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.10", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } }, "sha512-Fv+A3+MZVA8qhkBIZsM1E6dCdHNMyXXz22mAYiMWd03LlyK///F3OH6CKPX9mj4id7LUlxpr45yPzyBVy9aDPw=="], diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 45539070c38..74cefc973bc 100644 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -13,6 +13,7 @@ "dependencies": { "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", + "@lottiefiles/dotlottie-web": "0.74.0", "@opencode-ai/ui": "workspace:*", "@solidjs/router": "catalog:", "ghostty-web": "0.4.0", diff --git a/packages/kilo-console/public/logo.lottie b/packages/kilo-console/public/logo.lottie new file mode 100644 index 0000000000000000000000000000000000000000..d16c02faf118d5acfd49a1537a4a48dbe3552d38 GIT binary patch literal 32127 zcmZsiLy#!I&bG(4ZQHhO+qUgFW81cE+cwYGHveFSyw0s=2z+U zBV(YeC@`YHM3a@C@8qOh-u%e#PHFEWTSbu$0RXu4yYg)um7TZw<@~$ zjzzfI``7?GXX7dr4>;%%csX?|gELp;%JNu{*^5s_qh(Uk)ij8GR;3pACVu8d!kZi^ znWq`_RP!tRSE?ZpelADA002!G001oiQvH{yt)Yvhy`3|?i6JX9hY>3itr3GMJ1vVD z8z(IXJ0m+SD+3EB2PX%+i7`9N|DEVs)7E~I0pW)_<&|}QeU^H!RWhrzX0iEZDufDU z;jR!YD6zh_@?-BcJ1IapQ-Fd}zY@yE=d|mQ*S+`XWBjKPu<29hiIaJq#1PmL%cj=| z*seYu3_i&_y0jjKjhcK4^yNFe?EXv{CGJ+NXCaRv^1vGca*<_Pi4B9V@twB)B+39Q zRz`t9>`h3r!4_F*l|Y}$%L|fgIc58M#VIl+9(J0%h$#GuM+cz}okU8IOVF*VXyZ93TMdo#IcQrAt)nv^J6Lo;I30zsB}5^_J=r$VMa>G+MaC=M=j$ zzc4T|86QsfX25T4kV!V-B>lPg)PQP|5`*L#qe(EeT9`?n^UFMbhK_?cIwd9vf-R`R z$xHZq$3PwyBtSy)lPQi!S4*tc0}MjPHmQ#`@f8M&OReTLq7nL48m(lq0kyTcG9op3 zq%X-JL+-$2fP!mfvCbhIbjlKCQL0NsFU*=SCN3|OO zvDj6R4jWhwozJr%9)GncP1pmn?vB(30t2=-$$+x!eVF^BSk&d{0mc$C0nk4I#zv|3 zpW5Bf`o9O8lBEXIPR{S$nnBnTZovQ6*j_;Xy&l@)!`$t43U`_Yv&C2nVY826>mkYk zzaFeh7T?K_sd7l#0WYetC2BqwJ+y7yQioHF-N?Vbso1@?bMlJdos->lJVp~ou~V+3|6R84Pl z#Y!IK+PT>*eVNlVxakzbS1iQs5pt%?xoOHk=UqoSwGAv~v8bNEb|}5s)^xb(-_56VU@2a6TO7egN4*-yY4FG`juWJ0O z8Rn+W^#8Z$GtfEMneU`St0=2?jy04wyEUUK+BblZ77~D#NrJ$@*8p*b08{Kk4~vt| z7a0iz3$f(4M23fm5qWq(k%3nb^{_?C0_G%Cj!Vxe&TD`OjsQQzgTwpn4K~SxD z1#BDytQ3}PfntjeUW0+%9&pb?>HkDRERQa&(hqJ;O>ND3bq|;y+_PqlDEoul--;4g zy8*;{KEJfk)B%T9Ow9wh>w!Q;7F8QxsWJ9@B;>{Q4V;HXGngj@_6Qh#j4xd`1z5~9 z{joY9%f-j_@K{7sAywEnmPGqw4<3BLHTiH1YVh&qOd$AsQb?q8&JoC zWgW9C(97K;trCiOjP&jnKzrs)#vMZw&`hC19{;db-2mvaQvEcj5o^I;wNe2$sL?`& zw_d3P(Wp`)9n?ypg3RZW0bWvV95U?Wev^xjwG#^#;8NsJgj%fNRH2YSk2Z!Q$k9!q zf*y~wR^nYX}m$`omrGAWT-@HI4_%y#qlEdj4E?185L|-P$tZ#l!hR; z0t88O3e%9N32-O|L4Z?13E?#b<5IOHbR*_*juvvCD6TbO+noTRwAp~o)!{ursgy%L zH+fMd0*|j}2#W9=gl}Asz|(SHO|*s)7^&nu0ID zQis6PHuZF2qwkwYywGh#ue+jW^f{{L)bi)54*rhT$@?+)6HfvtyA~gUw*Ecuan*dfj+Fu+0&Ek z9ABn8ZO(5t*xNl0O+WoUyXcaS*tA-H!h?|*W^+MmU9rqI;%>01@aVE6)R7ZEyaPe? zK%$|X+(pJR7P2r=vo|f~;Fy%&N@F7Xj!RnjMpe=#X_8Z`?Ps^Ykvn!>3GHd@yvx1$ zwmg^__M{Wu)gJyg-wF>o$6jr;xq~TT;~jyyMl~%04Mg&33gptV0C4WUrMwg1h}E732Y0FQy+hHCJy6w{@(kZWSr&{uVEj2 zdfcP;R%tVj*R3d6`&n>4O-rg`E&GviEY`^EP_+Rqx#x+D|oZA?RTrV3!z(Q9g7OM@xTXEret05};BRcLLz8#gYH~u~h4K=^zIF z>n7Q`yGndgYvMbr`dQRSOc;4i)7Stjv%YN$^;jIgF&#&R0FP$#zE}9hd$QapPt485 zq}7ClyWs(fH7PYvC3F%^7n#;n)*F2$t9>I-L!8p?3#G9e_m`UxZXcb1YaQ1aJDc*l z0>%qw9ZKZd;z6eV4LmeMYJKQ>kT>VxOtgxRLdNSk>%*V=I5>upTB@NQ7Ls|WSjP#L z{<~O3h1}ce;@SzlqZA&Dqx*a^Z9U;5P-M@W?|LL@`9qQW2cN+)DCqU0_b$b*>9h*= zyOsBuq`BT%nim5an%Vt=u;e?M@>w_(@W zs-o1({_Hj!Zk%M?dQ1utg>6-I&(_lFTX*8gHFu)-5cSm38ZaI>!QgSgP57XvW2$b_ zp~uvvnjr`8D${s4R-1algzX_}L-NN8XNsgsamaWJq_j+g4W%$U(0kgt zeHJo*M1-y{H>X)BCpL%hHvcm&2Q@txaYIR;!qx#kYaeP>i!0J&oZg7M1_^&iYJBMo zg+E>QY%evI^4m%=%lKfvf2obX1Smzxr$pAigsR?vcVWuj0JYXidgSF3 zwSf7^D3DB^d=4IiH(5%(S5xz0+!y{h>wIG~lW2pcMKLMG6PB0QB^ zbc=ztf?$PdMK|@Y(0) z69L6ci8pHtKTBq;Fk+0IY^R}>IBR~ATaxt08nwgYB6^_uD=M6DYqj7lz`pn@pP>Fx z(@m~vWEi_LPe+Idj@(HgPBu>mw6VwH7_}5O+|FQY^Xtg%oiVV5wKmMqK^nd$``kr+@znh}51^5)yjNmiiX z(p|-@ObyS9CgyMIbjrf)p&x%1q8BwJJGY&+z?NyzUIWQ5GqgR&WE9Oqhj$C+1G$Xn zg{X+e1S5Y1l{>mOBCwj2H1`8(Wz3OxjcW3NeEOI`KS9;UCeN0HroM#N!(_i%)SXtW zCnAEyV#BP?xJos$TRvx`D-|HHbN#H(I81dD02Ayb4{Sz|E83t)h6ed^)G-F|#R}(t zoaKL<=6~E61K4s^6XQRp%s5KTYvErq#+YZi@1w^+8K`F{Azg zPL+FTPU_j4K20ox>m66)!akFKIm@3cKO_|Xxnv&{Akp0<7!U*y$Td5P2U0xW_Ge2V z0NZh<03D=z_;U6N5b&VB-^e_6v4C7&7$JA!6XSl%nblPsPvnqClcULo9Tf~P;UBeK$fpIVyfs`b29mO2emM7^(=xOfQ%^=_2VN?@*>#<$9 zOe;#U@(O7}CbwNm?!<>7gCPQMtA6V2Hn#cQsd`j?E~mcX;h{j1oWGi~IeAx*&yCe> zx+(f=v+LYQ&Xcb3_DIsWsOGS^shh7h>qKHx)UD1wl8!#l)n!lelTu84OnRt?f0u3O z9(QcqbSn$z{9*g^Q%+5WvD1R&dzUA4mwA!=KCtSbzs7Tosmw46(snA29BuRCxu^!Z zHRJ|DWqF_Vx?)L7Rf2)L>gVbhOJ)@I7_EjsMOITpH4-m-N-z0#m#j>B@YenzFzP_H z9gQqd^d1rkztnJkr#vZClkB8weeQ;X<|8P6oJ_T9{fMu7F8I^D5;+3hV*1=(@h{;5 zQ{j4ywWz5^zp_+tkK?KPb5&aw4g0->+NQtPK;sJIfjsXm_lcd??}EJS3@Pz+24NFj zpT$ncrQAw0vyf6N=G306+DDfEvA!QWownD|`BudOIRCj8?()&Q@Wj9CvL>lT$;79% zbP|#=rnf%-(#T`UTb5Ls`lW@cdC^vE!`Uh)6JtT%h+cMhGD(DR_)coX1pGlNU2L2= z#-uYm{O_`QtU}B@G?#z~u1kQjSOL9*#9)LWm`ib}WoN1oxEM@baID&XfUiU=;?Tx? z0z6JV{Dhg&8=#90P^`Kyhw?@gV3<1s-6!V^-iM&eD~JPUr7?DWoO$hoCPJt-<{JtckQ%u~6t7G7>uz zl$TH+rRP?pDc(VW_)~&b^0Y~`7sofL_tFyhH)ZLNKEBDdCFc4mLf4Z!->qyt&MJFd zjjs92Q{Pz)t-Czx+?B06Ssn8X6I647Eh&e(#PXLo`uXK^Oj`SM%AY}WPG`T<2=n;` z@aVFY+~R4%)f?E`T`0&Awiv}>v-^JX+1geKC$cF=p_74rynL;YjA=-6BorpS4Bp7EuN<2*N^E%eJ-jU=4}N>%Z#b=RR85C_S4T) zPD|q_+SkL+yZ}$CWHMa8?`R!~^F8mSM0$qyS{` zW?_l&R{}R=e8FLaRfMkaPz1#g@?kh5SP_yyL5K(p2#+biRs;?K0mK6AS=Vr8s3iX` zgR|*(r*>%1&d>H7-NwIDzkvTu&|0Tkcb5ZQh`vQ&u&DlG6x{Y8bF804uOy? z;;leQX@a1VOGyQ&7uRRHL?0f&PB`N{nIA{&XVrC2cOQRGS8J3{mwWVGFFtc+QrcQ} z)s&Q!3kcV5*Xq`$u3+df`PR@d;J zt}=Glo0t2CtbxKVRroJ196W2WzD1SBo-ZpPYb-M;Crm%$+Ab4^o*Icna4C|AOkh{2 zMO@1D2n*$(p%92G)dMh;d0`j}yf7ZQ02?EfFbX9rn!?!>3fTmm|iq>Lasd#0#-wjTv1N3<4qO zXB!Q|g(KX|1~iA^+8y7+kxM*|j0~DvkT3V;HkwM@i8me^j4l?MryfB~u&H0RQaJsz z<7~=WGKbeO)N(ys$hx@6NsT_~41$HSYW>ZNGlez)tZ8VPEwNELn;{vf@t^0`^-edY zFO0dB;hZ5!u9by8M3?0@0OZv5(cU%)FJy3UN zubdF*#f!^TvqH1fc=VH}xf z;6*vRq5DSTS_>ycez}Q-5e{y$m-@thKB8gkucE&69;bh{kokpJnuw2#O^ma7n*@xE zzeT|L#TEs@L>YdD(IiAYT`>%=X`$Cw!IMHq?|&)97~P&#Q`JL&-4`EFuWCM|$IBT? z+Z8sxoKHS}u|^x}F|iFQ;Bwx(;0>&+ol26gR(;1VYG+sGPM@PSL4Kon5X*P_((fU5Kbp+##?Q|(rsgVTIeR1TE@Ls4@%Nl3<0N)iz_AAZikcOd&r{u4Y&$?ZahQlPBM#*Cae1 zkceXFg&)}~EQBr=RA9P8mrgT*%sn_WNAa*T?L;)P9vf@b%DF33$Da2Lk-nqnloX(c zv5$X2e+JRPecp$OugEW&)Z)M9;QDn}`-*zkRTk4-rjq?KZLJixQ6GyE{5Iwk3Hr=0 zhkDTcIyKs*9TqI>j_K%!@I-PYcgD$h{$+O+OlFOdzvXSa;B3#hb#*CFZKq5>@fCeG zwb!wK@p|wN_j&$mgv2>kRQ~#dZj8^=hwnsE2(%aQ^sXNb?s#0%6jF?+j09e*2!TD9 zd}C;pvWUy2_)4yzO%l6_gvd2+J%g4yEMjyyg>JFa{FdA03Fjr%Iiq{B1{`yj+dg$8 z#7yQd{E=CzpXqUmA1-dAK~)!}Oz~1@Y#{3p;7PZ7@XIc{k>)xlI_Og$;>9dfPTB@q z3l>#6(sfW|kWlhM4I$pk%c7AFt7b5(7`QV>RRX6hBii$EVL1sRd_%D0dq2I*<>9>9 zVt8s%vQ@7KuN$#Tjjxf3%YNpf)o@77&bxXpySHrRUhTeVOJw-+o|9&uL(4>r#w+as z?AjNR+d%kZBV27_Rl)2Fo__#n@u@XzPMat-CsC9Ya>zaCSR@k_lhFs5f#Z;<3sE-5 zEPp&0FXu|1I9i(+4SH?^AKU^GY_5D>Nk{W zP)DBAoE(F?pHn@xzik6P`+3s48{qDagnI%v}G$p&XSJxep|#8h!>c;I_BvF7Xs4M}r= zrb?)uacVsuWAo*Gy~y~=UdK6iOk7ZPfJ)h#HHIb*e!#s-LI%&o*W$9P`Vv;B1ivE0ffg8K7Wdk`>uj&GV| zH=~K}vi(iZ$=5>csN>xZ1~IyomYNlAOwPQeaxn{FJs_52;bG15jg#bX29UxKk(uBZ2^M1K%LbPzGCBAOpsOM zN6SN@)3qLfA#{!?Rr91TM#vXOf@$lK!sW_fx<BAJNfEfeS(Vc9@mK)&QK%(eLip%U`FBLUtk zf`TO22>ol(Wl&$Z)`h^A`O3oxVw%RS-t0%{`&p&{IFkeBg))7{W;|7?QI7fGhhQ@i{ zIM8ggKIQ|quAC!EiX_FLpt^H)0;e%9@_^{*9S)5ExWavw`vn@8O4$09C-1}v1$@^& zu_aKeJXlxUS(-cU@ko$k(^I$w>*7v#Z>(LyjLG@g86uoz`1&UozaNy#J?m@yyUTcf zeyV7%CJeTUW{z#Hlg)E7Z$|8Ze;OJS6WxMwPvDlY4hURWTj>SaRUmQ#=*j#n&ZFdw zYMj(t5q!uF$#%it33i@G0Dw$0ajw;(prOa(n9k z_;%{K3&+^G_MCiVe${);RAx#Yds)Y2PBK5Q!dqdjTqw6LGrX?Qztf07zej1WY$(AC zCsD_jz8@?3B#?P(7=%LNV>o2pKsd3!>~}HNkj&TgxSqbEd?Xk&@DX@tXw!lBGBMkm zK1D<5GJXqc6XPt7k)K5n(x(+miYq0~7gvqt$!?wZxY?&i{34ckUf}OGe(Zo-twle3 zx<6ZVLVE~)G~1)!l|EV8L3?I^HG4yQezgL(Y8S&o;j?W?XWyf5SkDTV}S)hGRcqn z?rM&I1=%;-3V{>hcUxXr6`KxD1#zoLYWqtGoC8FHpl&9?#PJErdlqR{fP4?R`sw`u zot`u4sc&%YR_r>Zpg7F$n0cX%Xq$!B;;;F1T2@F`E`pq)NUvICX_0(6dA7e@ZqsrW zxum#*wE#UQW~+gGt%)$Q5KBi9S-BR0S8bhNc>!aS0Y+6qeCNYa4qK4o4yLM_pmArW zz+o!OH=?47i)x{$s%q)?x~Crx`FwdoHVU9ce*S~Eo(s#U(&K&+j^}h} zpAS4A!rPD~#^*BbVym`A{x-qGxz`SM!~fQr;m+cA&CcMsSW;vw7a5W~ysJ}jxNKcz zGp)>@9RtM~3y&_tO-IxJIrxiHeE#<4mZ7 zA6Jx+)mU7_a?-MQqdCJ!iB=x#*E+E6ycD*)$3?>~zxGh`8TX6+-(cnx6RQdGPufiU zU(zPif52=;15!o#w6)aAu^kyNV?Q*3j@%g&x z^=I~HuJ46Evb7$A%X!?nxw+YeCxfLmb(P#qOY1}2Tm~HN2Hu?-fCkkLR)81oPiX#E zu$ESn+=xx@VbcV^RZ~qFF8AbrRLxy2?J3=I-9Gtpec%IgaYpxHfUl#Sy|J$o;QTZD zjqa4$@J*Oy$wmId0E7UxNwas$WByjCEc`=k!L>yq{+Hqod*1DbZr_e+moCW}<2~05 z+$ibhvYffxzua@*ic?pHHAAFyImpSdH+Y7yuheggSGh*sU4Wv8kNeP7tF3s6`WhAO zDsB2!BjYD*5j4dr^j7$`2ov=LW0RiAT#3V>Z}8JZpc5yR>D1>?XskH&@t;~|)e6@n z50b8bx~JmFO2L3O5r~KI=CvqYp#f`fnI*&;S9WByGlN1O9GwNIbxVkVwYm`Dftw{7 zoRIVXjjS+i@k$X+zW|#7G*)z_w-R7u1?K%>sf+}+5ESc{4ltuva{{Od8wlLMq6}r& zBA#|(^pGlQw_CoO*+uRU2H2d*^H^ndtTURB2#8xUm2onFA9-7_J$h9_9ni}hYn*%| zmdD;66(6NRre4iVYfXNBEmcLEL0MVaMV6J6nq}7VY1K+g?8)a><5P~dQ)Y|88&`k} zNMdq953prg#tjIBDG|;?c@E*DynyvpT1NjUF6V!eoX4Ff7Yxpq5AqVo12GlI!*~wj zW4r+NHCl%M7(R0Y*kNKp=*Pswf-$IzhI-T-FOT1)Eg5V!FIIU=?NOqdhKmCJAW>Nl zXGWs(In{$iZ7rc-9FS#9?pS`zs`!%8zHGg%rP!1A$~+h99$BBg;xkd^Zt7p>N_FBw zvQNyW_#TR(VqnL`>3N4#=84`V)F3Jk+@g37 zpiY5(9yLF0-uGH5{RT68hQsm2E{RGrI**Bli3RWw`^749Vb9nuXI#ajT)5s*IQDtW z4d~Jq`V+j+{G%ISjgJG$pK|&t&yS0yTRkHHi4wHzjS^|`tD&Q>C*v+HHW>ZIg=jXK z#Wzf6`)$v1;^quKwTGk2$n(J}u;tj3ob8ByAKV#tXIA>!X&2h%+WjsJ&UH#y7VxR} zC1Y7X1Q>mxP3D>xKpMz{mpR-f$HL(X|um^b6uI!y%Pe%8v*{uFX zgfJRMnnNq$@wBxmRN~pN$}VhlRgzitrIVBpmSQs-1eK1Wu1r2q+uG#1H|f)bJtg-{ zdF3*W)`iL9$-2*6FjFm8wun`3Uzw5@_##+I^Tl6Hn_+8Z9i^kc47CyonZ#rzWN3C8;R;0#=c6!FKfX@RPDx5`5T!{+`$L1i1?J zlgf9*VkpBZWJL1@AY|J=t6R?vg2j&cTos>N1iDnUcZwn zkqZ%C+ag-#CJ>@yFiY!51Oe@+Uxqb}g!1X9XmF(ft|e$(<^;u@KXJ-HC~#|5F?j#V zaA}Wc!cU=g-ZZI{rX?-Vy6MaueNWBI;4|3|nGiSud@CD|V^%WHPTf^P+H2tXMcBQp z?*fi?!h0i+W9z*PYOeJqfi$igzqnVk=!b5YgnQ9B?)PecdAXFXscJ3V?d9rM+%-2n zWy_PM{m|QW7(0Ou6cL+l&zJe#UwM>`pl^5bM>zvw^UE`mKBsya3m22x-J1-L9M&}H zx2tco-ss8nHi@HcUwhOTOZK4Ny5~caOLd^X8CEx`Le7N8;NL>%Ev zN+hcaO8UIFz;i;*xcJv1w@QE!Kbb8K2@&7&-4Nt(V9^5-mIGa4rHqwCT^1(&COKD) zg^lDA;QIa?dvyQ9LuU*@6-dsORdcl_HtEc1?P+N$$(#xP;2pDfSf*rDtTN zD2JDPo$Oc=!t9|q_Vx3><}5$An1y;)&81ik!X-g_+&%}>Y5 zW;2a??yH#oYygWXQ!_v9`8utUHR19GY`kI(raUzWzZ^dc?)$mhvK6L-v}IYaK~s|twow&~T? zF?=?*@7iH{6O|Ue4ogyFzlfA|&uq@RL-XzWyizK#9Uj8g=>ddB#Kk4-0fhdED~iMy zyfbn;c%BZ<>Mp-rk06y-%&#_6N8=m@44tg)rb7T2NznCd;liVsT2Ddcf)r>lQXjPi zcA-O-PhVFswiU-QjYR~t^uzIFJ$9uf{xgC+lfqVF4%(H?=3dYk48P;&c2L|I=~=w? zhy}ys{oVYcjE|7KxSXw84+lbP3O?4e9FSimqn$Y6qZiVhqcpBGdRQkKRy;{6!mHNY zY?^WbQv+kkIZkRVGE1FK)9ZteP|e{#GhUw}QNok%&p6{h)VwyLj@%XqXthJ3wv>biV# zngM;@-Y=NooQNF^y-o1v00=+aublv+5S(sbIC=y$0%7RxuO`Cxy`gZpi_bcg5er{8 zmJtNsbjVp0Jh*_%SXc+ZO1~dQAvYbz&E;VzSJmnc(7UuG&r*G(TnQRtNx~PZ0FZeHTn*T-T>@!{9r(gG)`$b@DTZFqaHNifFC{dI$47KW`UPWMbWUHk|_4y=_<&Y#35=co#_{ zbD$u+W+pKYI}uf>6dTY;g40ta^cm(z7IK`LI?lq$GZ zly-1vW)FPJOVHI52DCpX#auApQ}rFVjN?z-Pz?#^?l=bsZf3oMQN;2rk9x7i!FXln zILKSK?bI55MWF|S5gi?x?wmR>xu*r294u)hWaFJX#9F?wSkn6uxjj8qR(ELoPG zL4DqJSvjoaGoCS~D(9)OnC6w$-=LPqOAn2CUiiPtYGR#_aAV~OJGf)FYMQ4IiyBLh zb}rs~YZHwVPH5E;P7&AXj0&bZ#1fwksV9q?f^#*iONa%K=p|E0(-V*0dt$m!d6%Jh zqu_Dp`dUrV?SU(0xy$Dr64F9CIBsBO>X#W9FRA!HA`n49cLONDhzLiqD|ysf3$Q^J z*-v}MR6JaYD5qxL3jD(i6Kwl(Q45KRRlH=0p;6|cTRUs-Z2M6K!yB8OQ_L?e=IJ#ex+fWKA~G=Yrp)r+wcD%wTs>ba?^l`mhp&@NXrtE-I2ao{FDpAvTiItWtNZWR zZ9z9{DH}d$cs!Mu z<^2#3n;EZ(9D&WP&o^CnR0$XEd3 zJ`td-tF&VvCJx*f>*gtFsPIoGTxprJRW8F#lyEc1Pj^u?CCdGl>9k-H4TWPOWP(d#?=R(33rl}4XQ30-AaTLj!T7zI`!+rM^FmY(%e4XTS)vDoxx=|j_V zUdqg!e__^1eG#3KSC)~eQ%81h=%5^=xiKaKUL?A=l5kcM6~^j#!0lJGx?>CVMzk_5`&`l1z%g z<)!264Jo40eyh_>p`lu3u?9P!65r}V;@9T=W!F#6lS=12il1A2U$r6K-uGP8Y<|S% z=YPrjZ)SE(_EC0(0|2=EUt%Wn|7T_t_0>@fbe8>aK(OY4`2avrF)Vh3Xb2ZpmZ*G& zAOW7nVkE!?u;K#JOv1?O5vxFf`9cKL90_m}b25lV7vxG*qo#8T>A-e@rWS$Uy6mrC z{=|p@X7kuy;P0NFn{PYSp3YabwJ1j?1FC8TeL`~bvi_jz?BgRe%c%)U|L?y>{cmh( z(tsAuuiqI>r|GwZa`TLw9mi*mZbe1cKSftDG{C1tFK%uxf4roR-JH{)`i`jfzAaqA%azyQ$R_2kd{?K7Y#4Ib#rSvzLFvEdUv}dT`bW~`Q=6@n&%`5SI5pkYNJlE z{8TExtbL}O{=P84owM*(;9-lcZLtQn=dK+xJ_I{N5VT;CwsuH-+jwu7ta6h_*_4$1 zK5VvIPr(M~b=clrEs7-mBrz7~lKAqy9^iL42d^$-;a2x{zt=T;{}#qO|XIYq>{|O1ovqVKecp*c>M;FDWCCXY8{I zRyGWSz0%AUhtjJhl#9PEH_Ut+@gq7ZA{|!FIBQ7zb*NiCqdFzFq^ejlj}&@ICG@kt z$9}<&7p3tX>vqpLbvc+AM^54cv_!xpgU`B6$kX0&_)*or2sSVhd~i<00?3SI>KGeP zu0Zw|)~J(#W?GxtFfcUF;JT2)g|>|tmtfCYflxq}!r8Z*{Yi37}_{Ai{cfR}UmSL@m(;tt|E2 z3#;wwm4O5MZrNd6r*=z3N$%^?_&g-tjR;KSMw*+utL4$=wu>XX>u@+3AC+~C0;4`x zO_4pAVLyu1hBm7sj}(k;)`4rw&JXK}5?=oqP+K|1ji3)bfM$|8@p3F_|J7PK@7k25 znjV`>Cv3OcAh%>>9-7>=XRJ?|4{DUp8bJk_p6Fud_+!A#&n)ZS9d}Otyi^A5mlDV; z%G>n5&dW?AcreW3nO zPq|2VeNWZ3PF&`7?_v$5@jF_19t~d?Y?H(P!Zq#Mc4-DrPVLB0{d-$+mS87|5Xsn^ zWx~1q5-byC(&D%p+7N{^HLo8vpBYVrkJzBC0)%n;4=K<@lex0);hel4`)QZolNUGP zD|zrweO$&w@qqeSkTfLOYy3~o^faNx71ItM2y|c6e(CTm;dm&Rb;!SFEm=hA?KCf` zyTi2Q7SJR81~m!sVzIC4*j0xuCUc~H(vf{_0E|R6Yn)TlA>87CEQG*oH+$1mml$aUxzpoa~06j6(!u<0_#vK(ASdK=XFP<9dCMpp7n$~`kPTq`j;Tj*+(e?lp+n)4qv63% zBz#jBsCVuOsAo{+>g~78mK43o>!a(5DluresnJa-4cI@zm!8@pxPqPBriY;U>`z#~ z9Y-9^1#54oI6u&>{7Q+kc0^Jb8#0XG_`!`KIJ)has8--t2q}h8Kc$#oVK%?Xi8K!o zh?C=PDEI?o8k#_}L~m49?R*AN65!m`@0Z|btH@Z5oYJSB?822;h-%%fEHhrW%lJjw z7Ej>1Q8M@y?qU8tk4Y1yMu zwsZdyJ-DBo=3Z5I8J6NRG`$_PvgjwM&h)bFEE0%Y|o{$FhhyqXvsKL!4Mf6ao)T=?RAX>(2Z#1S6cc& zw_k=+jj^o&yL&eGWu04LL{qe8TI9BK<`+xx8N?mae#RDpkYy&o$pffP;d!qMD5*3> zY|X|a6O<$Cu|3s;g6tUUpiBkcv54Hm`BOJQxSB@Jh$@v2>VJWI1Y8%eIsv*3@L&Qq zeAV4~kTi$(g-QstF^z!z8~hnevyL&vRzF9AD^F|u0$P$|yDI=@%2!DI#5LdYPGYP} zpw{Wl&O;eC+tQWSre3s30tR=u%&!Mg0u@H4KkN((Bgcxa{jV;Vg%reaV_G66LQ_9{Xd0E)pWc?!695 zGAk<6uHuG1pf|Xgr!DOD6l~-Yqk~&NZc+QWj|6;6cqFHQ#rubZxUgKkk#f?wp}jh{ zyG^tqUvKJIqGz1>2vovY61gf&-Wd4HlkZFaW(oP;>p&thZ&nRHj?{cQ z(poVn7%^we7qmJ@Ljw|i_Z<1&AwlcFdcJ}2D}S?xWqr5uyRCIassv@66GyYl#Na7GB`sW< zyVX2wxV(Zo_R=#EWApS_4w!sn*f}xh9Ue^=tnWP%oe(?v54lE{#>#^w;p^ zzLFccFG-Gym{1l;MtlanJO6r-)_nNYo5HQ>Gt#%f;6mQ45i z>h;}@gGW((aB#+u9j1~j>B3PEN$X(N7psYML*7AnzuTG+jhA84GUF51B}r2yUOOs0wcg))AnFa0PnWD4L(jo{_Mp@)W-g5};8bL@#D zkGp&Hr226_nqJzeV=c*Iq^Bs~$Bte~>JJ%*Zdj2qV0p>s7HNEb>nYi(%m0SL7`<5P z@5j_}?lwIK4$1au;x;THU2pfug4Z58TLt_nn-_3k z&=}1HVd8^V2VlDEl0zH^a@tQ3U?V)r17w2tZ$OP#%m6S^Ap^inffNuk1^O2t|u)xlKJieN_U$Q19Q5DQ=Ny#N;zZL8wu1O0U=Cu400va*YYXBXEO*JQF0$Z%>so2wyIiq|((f>=-W%&=FuB1b%AZxCU zH7IUxf)XLHwNL`0ItJFW6S$cQhCm#MRfRVTX~0N;KoSMTkO=HoVPf-dEEj5_fi`mk z1$EJU5Dq|5&{jnX%!8>AjsNtFO`kI_^7^LVFq>yfC+yXD-~OuZD$z^+`yRr4I^$BP2PHTSsz#T$LL9u@P9EGZ3&u_Osz-Dj!hP>6wI`gV_$*o>cAK$&W7( zaeC1$N_hJIGoq$Xkr|GH!h(&f;N38)|qsT}T4W&Gv z1mc6f%vAg3oO~L4P3k5`sIinJ4efH0JT0p_^5yyeI{T*}(V=b)7jE0OZM#?7wr$(C zZQELH+wRr2Z5wCp@4xu|y=tGElUyXJ%2TtFs#NBf;~nFTlZ)kLl5Wqmi;Po%RSdkv zAdxFs52Wc&3<00_&TY^wBAYSPBLZ7turHHfPlQb7ft8Jw3t`|C=!#hm#bVl216+e4 zk%Ju*MI?6dHzk4J2Pa_gyZM z>?~{n)X46mO3G$tJ;v>2!%?N9^Mh_KBrwi8Ypz9mOgxEH-_!wM$1nup?}XIdpIx0W z7zP>|fZZ>ItmIX5X5`CbTLkMrd7}7m)qOP!^`#JCQ;(U?$-VPU{Vh zQ4!E)d$=W_>Vu`RQhrF|QVu7SN$CuXz)O3VxVi zj!6advwY`qywBJ*{p%=%SQA+Sc{1pJGLC<-aG- z%!A>B_>vg+l5uk{Pd4RG zHP1b5ejTLavGIdvTwtnW#e^Kujc&Oc7Vp3vLvwi;);Qh%8SRVp@0U{>n6Tx%ioDy7 zGFd{Da``b~pwrU&>!MfP*j!qW0OUVGq#mCAbiS3hPz+O&pi@#>x9i$$LAUA@&&;ms|L&1f% zh_dfNGXsc0jUc{5jz*$?-LK&u23_f@3Bgt5^t7-CTMf}gHBtqS5U$B?k(=v|;wr$; zSk>6RD*&}-h=ch#f_<06!1Ewo+Ih zIBV~RyuNNDdbU(Cb!?EMFi=VQ>edYsjSU8yCnTT+vcSDe;gbeOR;^)>nI6BbPO1bM zYG0}bHZe7?Z5>fzT%B{`CLbGnS`IEgag{|lYzBbFXwN|wc4}1uTw!p$AcvSKs0GWG zL&z4w#s1GB2Ru^?-Xk@aJIlaW7)!s0Tn7jDh)#Io*R3lNh%ItXy$~Kf@Qo^(K=EZ- zT`Ji|%o8WNuZBVD8aKl^SK+XDe(i=DnY<_&_o1tuZX%?ge!&hUli{lOmC_o8<9IG( z{E8>}nd(c_d)KntI2@%4B5U)JZA;b!Yf>7vc!n#QFZZodb)z1d&OHj&h3Mm?l-#)u z%Gf0A1xu2N5Y4lTKtLG-pTf5z$Fwu52kImQGtI9GHA9-OdiM5jCQZ2$KE8IlTw*)w zf^DxWNQnH;XF)uOxrnQ7nf$_}aX++EP#{9#Y7!)6XQo#>kZ%He|8NuPphybQRo9{* zzDHba%gNV5JNU0*JXyV{^2<;)l~>mqzz7|?+56nLk?yJ*uhg^W$R{uP!ELVVn6QRu z<2Bah3ycu}48zh7tc64x-fv0DA1DhW;oOm2!3~mnq~>;2Qo$r*O=zf4n&Qk4 zcWW^Z>PdP)G|}_penuqS@Xa}N0i0U7bMG;{bG=+li?DC{wd^O!gH~{C4nDG1vQ8GX z5uP8{T*CxOhge&Es>YK2y4Hu~&=N2|RhCv>{hMsEm77RB3^2B9YXg*>8E9y!oC5Wx zQeuLShf;L2$PV)W&2L&%nbwEv(2`tLn)P|iTT|bKg6P6EBg$$b7dpt&5rtNdI1`hW zGW%wK^zU!SWeHwtYzsG(BdGM8wCcjuA&-1>==rYK$+`!gkQvCXTX-na{4sGXdpi*j z3}dyzyTH-!j!AR0^>~_()DjU1y|KH&P6p-6%4#;B-TR zk4oh@bTj>eFp7&D32ne^ei{>nbZDs6TpjjY}>_X zIqb(HUhF#M-FWt#;?!+*_Od2@coId8vVU-`Y*o~gzQbsLaM?>P0+Ex_K;(p%)${IB zW!>F>Ag>e736%EXw{gxONjA4$byTMKtcth`UJ{T6 z+6w<&B>@Ur70wRRM^qz_--jFW3?gt@ zSVrb9;7Q!&4@NRT&=!RFI}ZuCC2%BYL0%zP9Rh|RyF($$J`IE*10(!|kPP&~(&)c0 zjs14HPah-x8os)8sGHe-wu!z&hN(U4u|ujbi@#c0U-3r9qayA%c?no?@T*!8&IaT` zE*}O@jWMI0qhi^tk{=nOr4osOnSv3vevgi zu3{lvBVq=>SOI`SF3vjsCKAZ|vkM+_w=YVee6SfPD0Id`;Y^A^A#F4)xCce!AXr|- zOekEliz*_~mFF+-%`!J3c0S4s3hTs8hq!57`4R5%RZcT!s`L$ z=5z=SqxWH^iVwO|S?a3vU5k9@=aL|Mkb>JPTBR2YewFhEz9F&bA4go~c%$X~oJ&u+ z(q@^UQTNf4Zqm5UO4(Cub6(q(-y5bGOz6-?!#4Bv+H~uq`%PA5MyYBZO8qN?E+WUl z$9Wpp?5gs3%Nc$pAZXOAp~#xmCpxO$yax5BC2j59q2sYlgjyRC?b&UVTxgk{#6o!5 z1kMlo?yt~~C2i`@wrWWt#ag~=gSGu6T-W<%Y?*v#3UYhv?Z$8^96*j;=Gf$pgirczrB8*E!UQzj7rT~ZavM>%b) zyD+gu{v05EM@;7GP6QW0TSal+efyFnbx(a`$HEjQb4}{UbHI@IUPu!Na>vd)x5C%m z%pHQmtxvg#7`3a?eW_cO@YEIwz4p+@uXCN~`MHfTtz4axnHi=r9y1ot)>Y;+cTpeb z5Q7hP;sQPMiBK^)j?oRVmeuj~?!tR9ugf~cBWA(P^=xWgYBn9+`J62z=U@Ad$MKvm zWpqI;Y8gUt%-Bu2(w|S0co5Z2#K*n4=yX4(LuUQs1WrHodr`p0d7xvD(|&LYxjvHA zv;_%0P>)ndIv;#Bqj!2>o|Q9!DkEe+9bc=~sZuj}7R5{|t?k zU_!YDIYeDw=fujqq}-ythlrNK#72)kv(Rb_+Q(dXGtgT;x9zen*c6>U_RT13V1J{r zr4#rrI~kKnFPM@UdIkI7^+Y^F1JM|D_XVV)&v)}$fjTdGjG6Mal3&m2AArx?VzYpS zX|==S(olmJW#Say$5W;4O6d0GEP)f>92Kqx4s-9_;#)&hWt%v@)L0xnBwcAU&w{v>gkQ-KH~n_uSBFR325Vir2Low} z8hbO>_-^9Ai~rOivGI)+QkGf(F|V9Nr^r-e)MWE>+`Ea`4zJ=o{8-Cd#=L|va)q>& zOfPa%$2%N2i`JII!BO|i;edBAXZ9M{J^ldy?`YSHEVIJuZ?xXl)jYYEz}h zjfVxlBuNz&Te>Jsk%%c+(@)RjdvdSBG7t zRugrvd1^2iYv|$GVBI_S$MNWbCHNlJGW?TLVXl-HgwNxtdHw!eXMj=t;OOEQ??|K# zx!P@B_7@;7-o}R5McLmahNEn`SvGTv3}r6{ZJ(F%ycTWATzo_Ahw{N=&m-%>Fu=Io zkPJ~~JUQNkD}4_O4&RB^od`igmM+D!HLwlQ&LoAcw)3-rwt=0PQQ4)Ka#>uy#8)$! zH-6?tgH0*d2I3#|P&Avw=oBAZpwT|$RCtleoZEcckrIXl`a((%#B}M$ZWIIVhR%#C z&HTVPLnJQ7odaIFrIT)~gMkCO;oGES^QPnsN)g}4J}Bw-E&KwnBm=Go*g-*y-Xo(Ikj>LioDHeE>_U=*So-Nr58EZfgYHnm47B zjvGlx?jk{Yr-hdNv2Rc58lRBszfEHvXyC}z$hKGHNOPvcX@>lfAHQ*V<&nMh#L`tB z4_!FBqSa%~{9#{u50#MOphWf*T!Q1F%TnT}`hJyEAydRZ=vpN||1b$y?ELBG$|eiB z^L@HPL^)ypm@a&q4aFCZD@%T+9}O#d3H@G>gEeWw#jwT9`E|3q>Tij!tINIZu?bl> zQ?~bw!tC_TCY^;yd5Eh3M>Nh%m6Js?2YW3GFJ!@%v2)<65j4`y?IG?-V8t;aEi&vx zv8H;@i!pAa!32M_NVE;s9V-KRim_mkBn+qlZ78k6!xNuqLk&NOgE1-GWt8n*Y(S<1 zhMy(h>b*Gi?5Wk_avTydIuEaBC$l)^J`-rJs0%`6)$r@}@FRus zjxCWFK@EX(FXmoPL)Px812GpHkJoWs>#N$oqzC$qgKb=R(HyGk$jGTzAmV}dV0xdS zC|VhO&4oQR5$JizC8Ou#PJqxJu4`p7+@#BbhYQba>`uCIn3sKW+ng8K;_^x0#L}XPU&&^s8mB7(Y0%zQ6)Jd{wpjrF|Cxkvll~Q0%=prO)$0SwK$f zJc+v&lNmo_$~+z$o3|X*8cOP^s=MoLBjdOz4|8lJP*u}n#q{vGMBoCk^SL%p#;yXa zkhhg6Evt&oMUp}4OwqNv^1MHS#RPeABO~SdEKIfph5d!M@o1n0NMo0Di;Bnskh{f9 zDX;|AHew8Q!3Q0&UU*I0^AU7vK4K=;XWW7S%^l{+)VZWg0^@CJPW1i2kf_QZBcgtSWnZ*Vn`DH`iH#laiU0A~Y(`OF2>Hfq&GBV9%g1C`4kqSQq5WjN}!#9_7pVRaQ~tr01n?qi0}_#sTu~9b3MttpcKy z3|cy8g!i`sJ>o5=Aq$HM)O4k-3?8X=4S^sb3iMb5G_LS)dw}`w3<#%9MsiSySxR$z zk^vS|gK&lV-~^GLM)2ywC}%sv_0`?hgzGQ=3UN2EeE4}E#Q0$69q{T~HwEbYB}61Fl@rmzGAnJ0CMKD4p2@XRs3+xqNjd za4)(YT{@fU&bp&*CCWtyNH6&#GkiS@WZOow!{1*=$8} z%1tUFQ`?%QCd{b3YGXB_kG>a7A3BAS(Y8KOpV(8IOk}f|;4LZ*;-oUHZ+>0kl>8Os zHIXIJb}i+en_G|D5fs2dNe!K`y>h&58pdk74urDz(|=J zLfJ5%GSs3;gt&l>7G^74mZ)_+gPWLRcGxL();V@j_0)J$9%}r9Ep2T~Q7R70DP=q; zK6LIu9Zoy#mJ~`qO$|l1P9)c_Me$3tX}A0j!9>&mk)O5x!S|r-B*2Y zc~B&1Hr!;Eod_8_kS4PQJK*ma89OkN#`Y9c#I8X z7?d}(fK&vC4>Se9h*u}-U6s+(g0m~;T~q##Wny;hBRD8; zC4n|XNSVP0!uO_Dt}`Sug;GxUiPKG^yT7r=KKM}kjL+5CM+RXUd8mxwA{y+(w~llg zaQq-ufF1W+7}MB%Dy*M%?R&%h>+Omt&Uw8Xdog>?k4JB#lZF!m?*;&9R{i5O@IQCifkWE$U6W*#yS*1p)MeT zVih3BB9=diB`tt-H(^IV>5FjV)sNgX}f@TdW# zLqs2cFp9CUY+JhUx79B|neg z5fW6TP*Uq^LqH>H703WoISxeo*c>(C+GWbzv;{T7K-UF-c^wyIa59-1fp$bh!0k`| z0skw61ehSB2s(tzrc5AYfw*wDB!rA~n;=1x9TWHf?W!gLO;BY>Fat)rDNL}j4i#aS zhSlBuW+xu;d)WJN>a`|^%;!{065lM3(fz?XY5#(^k=&{7Q~xn2k2e?)!M(PK`rgCxkEi_x7Vuo~7%^U_#f>*~^ueM( z1HJn5RKsoCIf6*kS{X0(m+#(4On0CYYE^c8Wp*uz9WC9t(ZP!vG~>L}_VKYS0u^s0 zIu!-0`>e-Z`0$-qIjzvwS)I_Oq??lBxu~^1nj2G@vrUO|T5lD|#cVV3nVSv#8^<XgdkZP9r&}>^vg=UyDQ>ra1;E>_o0(=in&fd3>czDpyrlAP zYH>(9OdEpufS*X`W#igjd;5h<1^_N}dJ?-QcgLbkvfATozZ{&htps3qUa|K2mQ~^5 z@NAZqF+Y0`v6lEz-PjJiA2&bIE9T|4XVG3dMpxESp*~c_!LgFKfv14qxG>5(&`r5e z(ZJKe+bW%=jw-+4Hr>cx&s4-#z(qILQGGROT4u8S+U6zGG#Ha&#a)k)Zm+!XshOEh zZJT6kxXTzr#q)bd;HG=02Cw^F_9=3ZdAXT$7{qaRHy_69@RQy!+D+Cop?i@~?y~@! z^hi{v&behKU9!hs{W&wZ&GLy55w9w6#Egx(%~2X zGq1O8Nvr`@Z4GxvANFwjP7|xUEOusT_(w`J6!c=6KG?7huZ(xI3$wR+)A97*wnwl0 zUX-eTt83%_uh5k3|ECm&Rz_B99Z~#qE)wDoh0zp&Py>`A)<=-xiaxpvZLedYuM&^Hur?Cfrn!(5tGLtIY=hJzPnsPBeWI z2l#T0R=t4z_Fr6jwN{H<1U;;a_%|rPbBG>ZujWU5hF9q>%v~-Ksj2mruuT(gar+q! zREAbzf7shl?>sgYYLMRd30@|D2Rj^XCQ&~_ulHKxGI#z=JL8WyrlHcUy$q=}Ncyo< zeD9@+HK3kQH_(S;!Q@uY?7!^n843rMlq+X}yCEqLx4sqmGWO*GeyYt?V7P+fTnBxBrf1n%?z%;dpEaGd~Hd zueGE0nJ(+*_)(Aiu%A&231XVIP`h4=cpo9r4RDkK@$OJ~o{S zibOKA%E@&|ZtSr-dK^MJS+I2k*6)BLRfx6UX^>yERw;Ns!@U189#J@paJQ0@`tcwL zgMGP0*0*;|Vy{iaKUo0mmFL#CX#a6mAY40Af}`a;#7d4XBo z*(koTHL>U#6l;1xw{wiMb{+n~tLY=>$NQ$V9<^SsDHY)oF~n|=W+#HsP3aaCYpiDT z#F-QjWP#oR4VIn8_REDwOUZIz#tm`hG=b^)C_j|b6`bz7K~hY6B2c6fOX$z4YET<^5s(Gp)^4jok2UxKH;P6 zPO1jUnrhM&<@o~g9Fv{k@xB;~Ob-IB71}%tVYEXEE0NAiS@_NjNgZ4!7rUB`_Y!hb zyoEFLv4&sVnBceAV-K|5r&2)+8IwI5;6&ER4f()cp|F&Z0D$cXjjkeIAnw6T!Z>{* zc!KS=5C@MU%lms3Ma8xwJ*k%Y;NZjsRg%fZi&C=8;cuZhZ-I4;D{OpL7sI_1p=*etBgr6`^#=$@@km1 zabVc4^}V~?$Zb>Ydp)lKyuW!0|c z)6_BZCe`#UO`%;y7CxLK{r&ZV+hMqI#bbwIV_Ei}3CN{^8Lv8}nS`cNx+izps+9)Otnob?eXiz#Kd=fDX_GBTjK z?zBo@85~g1yED=WdE|10z@f4&!n|A|WviQRps{k5rO-roOr`6N?%bt^Efa^Vhf2o! zu|wk@02;y4K|o12v=nZ4zl384Ld|&=Zp7X0yXC^X_5@Bq;pn|4GYQnvDzbd<1J1L{ zws*+X25Bg>w%ZZ-2B#R+hV84t+IGpAh?{73i)&9{Ognz@1Gmx3;!G|pctF46yL@+nxWQKtoeZ_QY$)`Y zZt-9)$#aJ=@n`?)S9&b(7mbhe`|m zIN2pVv-*tHl6LYj+&aZKg!A=V8nS9rVpvk%6@t%a3v1ofi2DP2iOx<~WTaoP7x>3) z+glN+y`7+BhdCTLR4ooir`*-btM)h9o+rEPyb8ZNC zr8bSdr=AUe89n*^HZhpL(_Eu@OtC}jF6^~FMGnNl_@b(PZeQ>&5j`84TLIv7L3R&@ zRGKZ)q>Vj9h^&&|1YiVWwdqmE43$&lnvSJYWAR(GS)LXR*YMS*-^yTj*Iha=hNE3$ z{cy14a$a&I6g7=<|GM&{Q1D+4zi7{WOzf#iP!w0{P0W__d+v3tYtDhMzTB} zFo%V{9@fw|(!NYdC{f?L3V*H)6Cd2d6Rkt@W*xaBp;?K$xXpUzq+}E@pm&k3!EOIQKpWe@+rowUC(QLEiF>zj>s3!?3ehJOjd7a^# z;-Ckjy<%WUX2^OuNd^q${vPLmKw*Vj>T+{JR^IMlQ*!IFa{6ecLDoLY6R zxt=mG`%*gWo6PqW@sYL=>xJLg+e{)^uL1hyU1EQ;-88=brh0V^`s48FQ0h}=lYA$; z)SOBkqV`g1Vt(@B+{TyZz;hh@MP4+zcg&g4c0slnhB)U$r~XXLfi$TCI2#>U%&aJq zNZ-!gFUXDXRVHZlG(Rrd<{|k2wp%j|liq97OKC5O1gfom;i!^lJEwuyLW1E=Pcl(J z8Sh8djt^H>Ap`1Ao~Ut288_@)(y4f8>JB-;rnHL4ClJds4FAdH4lTp0g%CxBDJa(t z-{MeU$cR`g$C^UM;JebDab*KxidV6^3G}6{(Z;LWXVAiju?hF;8r~l^+<$9=4PQDE;>X(oK1e%T@VX(-LZ~;D=jtn8X zP!EV%h;a*n@n6puN5X?)Awi19sprBe%=7n8`lmTYwFlzD1QotjR=39+ zc1qIdba61dtQ#fPc%>i1sLem<-Z*s@NiomhW*%=w%pR;oeAevgI}YrX;9a`c+A=b0%MTuvTsnWa-H_(=CrS@;_2xX1G|+psTx8w;!MPQ=6w zIp-S?)mDN>1o;dyH=%}L$Pb+l4F{NM66wvF&-#D@!|!BN`2=u-pYuyI=w8f$aj%9^LQS&KECM3lzw(p z{$QFZR;YDM_`;@>;G;bU^wVoTK=}`u{mWd65u~-G=}fgT+;;3D{@&58*RD6ktPoz< zaG@^Eu%jWYER5`6e)qpu!NtJro8m|r^y^1MTt4xc(%Kw@Jw?t!qQ$51k}lnwyUmj#RP);>SV-%vj9$4noM zp>Cw=C6dHgCtbdrvm#?mY%}fH`cmPKWyIhz~o`i1w<-iZy zsPG;8m!Cpt2}#MKi12G+oD*1b(Q_A{KyYm%5IGYKf2MxdH=Q5Om2T6Ex@H@)tJ-5( zEtFDtIcKlRiqfUfm2UOc^5k^}M*k)Zfxn;VwEHSx!W5swf@`6a-+33xF#4&ET3WtZ z&pYuP|IW7Zd(KI!O?Y|u=ymZ#bfnvM3vO(xH~Ov5(n?j{+NhGOE%SnP@l-Qf`R6w!*Dl!;xCQJAFxA`sWY7=n|4tB>nUke` z{%t<|{(sf$vj0yMzNK!Zj=a(_B2_;hgQ^r9td(Vt$QpR zA&7zmf?`1!-xu5TTZE1c6T3dkQ>Y!8hKS-QF?_C&zqv314FOWLkHif2DlYPh^YOAf z@BQ+LKroPEgQD_|7Jq!v;(IqoOd-U-tE!3mqj*6HdJL zQz(gHsj?TPSG_Xmh#mrzl>Lg+=>~Z&F2l%{sy3 zJD+$(Rz^RtzqknNrtxJMc%^!$*6WzRBVWJH7MsCxT1TShv&F&Ir zFS-Ypw0~bUN20QxB=3|oL2YNp#MO^vJxVHxm@cnq<5Bjjsg-oaeW|S%FS>ljp5rK# z;n?Ruu+ckONk6C`RX8-f7TwHdZ{9mjI^)0jn}{_Ovv*gQ~&X!#AWH;fxS1k!f_O zydbFsIXa1iQ{bLO1U?>SK&_&s6h}PP`s(CYZd{j%-giRZLbn+X@19mbD&WQmaN6p>E^x+J;U%betL$c?$Lh>*lx@)UjZn`4k!lDeA_*&{z$Mf7 z6KSzbfT*_F5?uG?pyMPuGj8DGJjXLTuJgduCRr`%vLk6((kaH{Q`QeYm7ug+;#zY@ zr|jkLk3sNISgXA-F0-kMf<|#x&WC@hYij+gylq!zE`Of?Og3iqWa_(8AL*^4G$wkQ zl0Td;cGZ=yE>ANQOWr~FZR8;0_H&>tBpp5bbv6+)>rFjfgta_7{maZ8mumI)dS^${ zo!oWCpN$WU)&5>nFJtMhr`rXSbd-j1i^&A+-zx3+j!?N4Jv$F-4}(9Y35XieVP2uB5TlG{wfT%fMs@H{d!~y;YE@AZ5W|6#fE?_ zl`P76(T9658Yt{@2$g*x>k?YE-`F}u%;y&ZS$4Nzj{pwA;^?CZ0v9R*O|YjukYF?E zMf4@xu+S}RaD?%}5%+L{pid(|-k#Smn>rZhF33f@xoQbb)I0?QkwKvf-95-1@t(FX z<62)r=i+x|Am}W*RIs`)BjrLu=TMIVqsU!^pO?BC_grZkIO}G+y9{9vmS}-#czxP* z@VGqJ&}(ovI$RJBAz0EE<0oYkfT8ejx52UkQzj#PFY-;0k|fNJweMSkLkZL1->riq zb3jbQ%FVT3Q&!BFxHGm49`Q&R*ZBJ!PA$!8ge$Hhd*oDdf95}#W!MnG-J`ec@O->d zpU`}o^<#G+JY7vjz6Z*NXgLKI668qirxQJ_$jNQiQF`o6Q-1*W?o}Si?$;zE>m|;7 zE@QX+9Oe|fuK^ZW9$**>D(e%H=nFT80|U&mkAJR`O*G4c-Rp%-A2mTATbVb~c<87* z#_UGxiZAxhlk-k!wPaUd!>`v5d|C(BbF+f{-3QlW%?ADD^=rT$fDn+XO``ljj$h6w zpU90`JrK1|DOG~3`Pt@5)Nu9z3Fk_laNr1HozIR&g!xtHOI$+7{b`D%4){RMq@l8_ zHx`ZK(r^pRjZ4~nVHAjMyVjUvNA_tat|#bWTz}adNaus&TYB7k4MGpK-O&{zmM$gA`}gD?S|F#PNGv=~NRltMmm&?U5Xgg>?(qE_v-GTuwH z#NX{PWg3ZTIhKq{c|IumX99SaaJxWSh0KCDLkcu*v(b{e{*TxG&3oK*faCW&)?3hk zM-9$-r14p1#Dr^z?$6Ij$L9^PG4o-}5Y74|J~S~}Q=XPizN`1tJ}746d)BPDb(?Ye z0Ri2N(MUgz8;FoWEPS9Z1388Od^&Q>u0VQHpgsoA9a|vxE~FC3`0WrA2z(zhHUdll zIR(V~05Uh?CX5^dAQe9kVE;4&`Si+YluUA`Hu!;Bk$S3uPHXDp9VDk!zrz(ds=MLH zqy+xnx*(`_sCme30gU5tP>6vwmk;`-Gb-mLX|?Mi%XuRNl9ImM9ZmQFaOX@T0eZjr z-e8KMoAufd@okt-TL-odyZx|k)>l|kg{3g~_K6iT)1@61ohABrmmI6{0e^7d`n z0(ux>Z+`u!W0<#hlf*Q$i~WL^$K>VV9bCvWwNGomE3tcT4$-fYB0der+$MPpXLo4U zV7INt_cd_aymft=-5hd+)IezJvSpc-_9YRX)GOId>o;ZhJ?tH^G~9ugY2Ql zo6_wAnSL8q4BhG6<;=#wOfy_oY-X!iAGL){+&(KviUZIwTd?T7PZ(SZz4K2+GjCnl#Ub;flW(m*jt?J2(nZ z`KgdtQg}?N159-+VXY)++2NFW9wvms(ePk@hk%Ri}SVZ?{A1^jFQ};U>VIMz(4|2IvwgdfdxM@_`<|+cH5jA2KopK+UmX( zSl;dtisU!=vNLgjQCk5NvH<7od@`vFcts`I-a`dUc^V1~(3|)!IKplKKV&9p3PAui zL3{*=e-RMmp+7$wL~(k(H(F57CPOtGNVC=9usTEfh=gvmkbQI4DwY7dNBy)hN*9II z$Y4koMW$;=X@?H!R~FqBJib)AOC&jJ;&?pK@H{5GyG?nAb@Iq!Ecco8mp5;>#I7Ge z>t88i`fI4d8&5(paa*k-cZ=9i@!QnCf^`f1Ae)OlAx@1|egrrw(~S~cf<22M%M=pk z8ftY~=NOZ`4Mu9XM~oRY!7Ji*(tdv6Q93Y@mv&aYeo!=h(=?F9jv`%x1B)wCHHgI$ z-Tfc|?OdP>N^#8u-44*aCjl5*vB%y-H2*sKK#4xfaBFg1zxkyx2TU<%=R}TxW&-Gi zRJ`aUU0G0*7y>ZnG`w19w5wnnN_1(P=o=R16$4Lrar;<1!SsM&MO-9frzY;08@!)VAK5@Q~dAb1)of z*85mNo3WuFwS_ADk50XvF{1BA)r}OS?$LA?HMX()n1{xV57HlHkI;+|sz$|JaQ{|C zRiX*Sj8F6i!nuiqa6xKl z1d~b>!U<&$H2$oVdm@0!lm;w?R2q^v%kg&@1vDCx9LxR*(PU2Ovk=Rf387?6=(A|c z5h_s>MRbkw4Y$jSOEWbqdyCyYM3V!FK)c8codE7`jX4(pw_Cd5z}MqHj(#e4(N;3` zrwL|;s6jfI-{*1fW?3pBRIjQNp=qqw{q$Sts5AS$JO}(O5+}a9Cdh9ywSP9oBpXts ziG3Q64i^6>V+#m`0`ULtX8qUi_5W}CU*6XLqW`)}|Cg)!d;0&yYx>_2{&k}L?+AM! z|CcZAzx=;GJpbj1|Gt6$m+vC{hyPFW=zsZtTW9^3zh(G`|4$>W zfBAnaoBzuv@cqO8r_T9b{$H*5e|bOIfB63>$N%O3P3ZrZ$I|(S|4(}VU;f{vi2w5W e*8lMTS(uQQ0tNe@d!YXAIY0maQ=b2G_5T29GE>C> literal 0 HcmV?d00001 diff --git a/packages/kilo-console/src/components/LoadingLogo.tsx b/packages/kilo-console/src/components/LoadingLogo.tsx new file mode 100644 index 00000000000..6e261e69fab --- /dev/null +++ b/packages/kilo-console/src/components/LoadingLogo.tsx @@ -0,0 +1,34 @@ +import { DotLottie } from "@lottiefiles/dotlottie-web" +import { onCleanup, onMount } from "solid-js" + +const src = `${import.meta.env.BASE_URL}logo.lottie` + +export function LoadingLogo(props: { class?: string }) { + let canvas: HTMLCanvasElement | undefined + + onMount(() => { + if (!canvas) return + + const motion = !window.matchMedia("(prefers-reduced-motion: reduce)").matches + const player = new DotLottie({ + autoplay: motion, + canvas, + loop: motion, + src, + renderConfig: { + autoResize: true, + }, + }) + + onCleanup(() => player.destroy()) + }) + + return ( + (canvas = node)} + class={`console-loading-logo${props.class ? ` ${props.class}` : ""}`} + role="img" + aria-label="Kilo loading animation" + /> + ) +} diff --git a/packages/kilo-console/src/components/LoadingScreen.tsx b/packages/kilo-console/src/components/LoadingScreen.tsx new file mode 100644 index 00000000000..e1ffc181d0f --- /dev/null +++ b/packages/kilo-console/src/components/LoadingScreen.tsx @@ -0,0 +1,20 @@ +import { LoadingLogo } from "./LoadingLogo" + +type Variant = "fullscreen" | "content" + +export function LoadingScreen(props: { variant: Variant }) { + return ( +
+ +
+ ) +} diff --git a/packages/kilo-console/src/layouts/ConfigLayout.tsx b/packages/kilo-console/src/layouts/ConfigLayout.tsx index 09ce8141bd8..8f779b93283 100644 --- a/packages/kilo-console/src/layouts/ConfigLayout.tsx +++ b/packages/kilo-console/src/layouts/ConfigLayout.tsx @@ -1,6 +1,7 @@ import { Show } from "solid-js" import type { JSX } from "solid-js" import { Card } from "@kilocode/kilo-web-ui/card" +import { LoadingScreen } from "../components/LoadingScreen" import { ConfigProvider } from "../context/ConfigProvider" import { useConfig } from "../context/config" import { ConfigSidebar } from "../routes/config/ConfigSidebar" @@ -43,9 +44,7 @@ function ConfigContent(props: { children?: JSX.Element }) {
- + {props.children} diff --git a/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx b/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx index 294b1ca2e07..7f576e7fb60 100644 --- a/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx +++ b/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx @@ -2,6 +2,7 @@ import { A, useLocation, useParams } from "@solidjs/router" import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js" import { Card } from "@kilocode/kilo-web-ui/card" import { Icon } from "@kilocode/kilo-web-ui/icon" +import { LoadingScreen } from "../../components/LoadingScreen" import { createProjectPty, createProjectWorktree, @@ -677,14 +678,10 @@ export function ProjectConsoleRoute() {
- + - +