diff --git a/api/boss-plugin-api.api b/api/boss-plugin-api.api index 66fe959..0020ac7 100644 --- a/api/boss-plugin-api.api +++ b/api/boss-plugin-api.api @@ -7967,6 +7967,34 @@ public final class ai/rever/boss/plugin/ui/ContextMenuItemData { public fun toString ()Ljava/lang/String; } +public final class ai/rever/boss/plugin/ui/TerminalTitleBarAction { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/graphics/vector/ImageVector;ZLkotlin/jvm/functions/Function0;)V + public final fun getActive ()Z + public final fun getIcon ()Landroidx/compose/ui/graphics/vector/ImageVector; + public final fun getId ()Ljava/lang/String; + public final fun getLabel ()Ljava/lang/String; + public final fun getOnClick ()Lkotlin/jvm/functions/Function0; + public final fun getSymbol ()Ljava/lang/String; +} + +public final class ai/rever/boss/plugin/ui/TerminalTitleBarBridge { + public static final field $stable I + public static final field INSTANCE Lai/rever/boss/plugin/ui/TerminalTitleBarBridge; + public final fun CallBarContent (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V + public final fun Content (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V + public final fun actions (Ljava/lang/String;)Ljava/util/List; + public final fun hasCallBar (Ljava/lang/String;)Z + public final fun hostWindow (Ljava/lang/String;Z)V + public final fun isHosted (Ljava/lang/String;)Z + public final fun publish (Ljava/lang/String;Ljava/lang/Object;ZLjava/util/List;)V + public final fun publishCallBar (Ljava/lang/String;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)V + public final fun registerProvider (Ljava/lang/Object;Lkotlin/jvm/functions/Function3;)V + public final fun remove (Ljava/lang/Object;)V + public final fun removeCallBar (Ljava/lang/Object;)V + public final fun unregisterProvider (Ljava/lang/Object;)V +} + public final class ai/rever/boss/plugin/workspace/BreadcrumbConfig { public static final field $stable I public static final field Companion Lai/rever/boss/plugin/workspace/BreadcrumbConfig$Companion; diff --git a/build.gradle.kts b/build.gradle.kts index e6b0ad9..d2e2dcf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -594,8 +594,11 @@ group = "ai.rever.boss.plugin.bundled" // CLI selection, rather than asking a consumer to duplicate routing policy. New types only: // ApiClassLoader/minApiVersion, no host release. // -// Next release (1.0.94): optional native browser title-bar navigation bridge. +// 1.0.94: optional native browser title-bar navigation bridge. +// 1.0.95 adds the optional, window-scoped terminal native-titlebar action bridge. version = "1.0.94" +// Local integration builds may stage the next artifact without advancing release CI twice. +providers.gradleProperty("localApiVersion").orNull?.let { version = it } java { toolchain { diff --git a/src/main/kotlin/ai/rever/boss/plugin/ui/TerminalTitleBarBridge.kt b/src/main/kotlin/ai/rever/boss/plugin/ui/TerminalTitleBarBridge.kt new file mode 100644 index 0000000..be6e57e --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/ui/TerminalTitleBarBridge.kt @@ -0,0 +1,154 @@ +package ai.rever.boss.plugin.ui + +import ai.rever.boss.plugin.api.HostImplemented +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Native control: [symbol] is an SF Symbol name, [icon] its Compose/vector fallback, and [active] + * is the service's selected state. Preserve this constructor; add compatible overloads instead. + */ +@HostImplemented +class TerminalTitleBarAction( + val id: String, + val label: String, + val symbol: String, + val icon: ImageVector, + val active: Boolean, + val onClick: () -> Unit, +) + +/** + * UI-thread-only bridge shared parent-first with BossConsole. API 1.0.95 introduces these types; + * native hosting requires a supporting host. Future members require a host release/minBossVersion. + * Unsupported hosts leave windows unclaimed, retaining terminal floating controls. + * + * The terminal plugin registers one provider, pairing registration with unregister on unload. + * Each composed provider pairs publish/remove and publishCallBar/removeCallBar on disposal. + * Publish from effects with stable callbacks. Owners are compared by identity, never equals. + * The most recently changed registration/publication wins when owners overlap; removing it + * restores the previous owner. Entry [publish] active means eligible, unlike action selected state. + * Hosts release their window claims on disposal; plugin cleanup cannot release a host claim. + */ +@HostImplemented +// One cohesive public bridge has three paired lifecycle protocols; keep existing ABI call sites. +@Suppress("TooManyFunctions") +object TerminalTitleBarBridge { + private data class Entry( + val windowId: String, + val active: Boolean, + val actions: List, + ) + + private data class CallBar( + val windowId: String, + val content: @Composable () -> Unit, + ) + + private val providers = TerminalOwners<@Composable (String) -> Unit>() + private val callBars = TerminalOwners() + private val entries = TerminalOwners() + private val windows = mutableStateMapOf() + + fun registerProvider( + owner: Any, + content: @Composable (String) -> Unit, + ) { + providers.put(owner, content) + } + + fun unregisterProvider(owner: Any) { + providers.remove(owner) + } + + @Composable + fun Content(windowId: String) { + if (isHosted(windowId)) { + providers.last()?.let { entry -> + key(entry.key, windowId) { entry.value(windowId) } + } + } + } + + fun publishCallBar( + windowId: String, + owner: Any, + content: @Composable () -> Unit, + ) { + callBars.put(owner, CallBar(windowId, content)) + } + + fun removeCallBar(owner: Any) { + callBars.remove(owner) + } + + fun hasCallBar(windowId: String): Boolean = callBars.last { it.windowId == windowId } != null + + @Composable + fun CallBarContent(windowId: String) { + callBars.last { it.windowId == windowId }?.let { entry -> + key(entry.key, windowId) { entry.value.content() } + } + } + + fun hostWindow( + windowId: String, + enabled: Boolean, + ) { + if (enabled) windows[windowId] = true else windows.remove(windowId) + } + + fun isHosted(windowId: String): Boolean = windows[windowId] == true + + fun publish( + windowId: String, + owner: Any, + active: Boolean, + actions: List, + ) { + entries.put(owner, Entry(windowId, active, actions)) + } + + fun remove(owner: Any) { + entries.remove(owner) + } + + fun actions(windowId: String): List = + entries + .last { it.windowId == windowId && it.active } + ?.value + ?.actions + .orEmpty() +} + +/** Identity-owned, explicitly ordered state. Unchanged effects do not invalidate composition. */ +private class TerminalOwners { + // Wrapper identity also gives Compose key() identity semantics for equal-but-distinct owners. + class Owned( + val owner: Any, + val value: T, + val key: Any = Any(), + ) + + private val values = mutableStateListOf>() + + fun put( + owner: Any, + value: T, + ) { + val index = values.indexOfFirst { it.owner === owner } + if (index >= 0 && values[index].value == value) return + val key = if (index >= 0) values.removeAt(index).key else Any() + values.add(Owned(owner, value, key)) + } + + fun remove(owner: Any) { + val index = values.indexOfFirst { it.owner === owner } + if (index >= 0) values.removeAt(index) + } + + fun last(predicate: (T) -> Boolean = { true }): Owned? = values.lastOrNull { predicate(it.value) } +} diff --git a/src/test/kotlin/ai/rever/boss/plugin/ui/TerminalTitleBarBridgeTest.kt b/src/test/kotlin/ai/rever/boss/plugin/ui/TerminalTitleBarBridgeTest.kt new file mode 100644 index 0000000..e81cce8 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/ui/TerminalTitleBarBridgeTest.kt @@ -0,0 +1,124 @@ +package ai.rever.boss.plugin.ui + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import kotlinx.coroutines.runBlocking +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TerminalTitleBarBridgeTest { + @Test + fun `provider arbitration is ordered and callbar owners have separate remembered state`() = runBlocking { + val first = EqualOwner() + val second = EqualOwner() + val recomposer = Recomposer(coroutineContext) + val composition = Composition(EmptyApplier(), recomposer) + var provider = "" + var value = "" + fun callBar(label: String): @androidx.compose.runtime.Composable () -> Unit = { + val remembered = remember { label } + SideEffect { value = remembered } + } + try { + TerminalTitleBarBridge.hostWindow("composed", true) + TerminalTitleBarBridge.registerProvider(first) { SideEffect { provider = "first" } } + TerminalTitleBarBridge.registerProvider(second) { SideEffect { provider = "second" } } + composition.setContent { TerminalTitleBarBridge.Content("composed") } + assertEquals("second", provider) + TerminalTitleBarBridge.unregisterProvider(second) + composition.setContent { TerminalTitleBarBridge.Content("composed") } + assertEquals("first", provider) + TerminalTitleBarBridge.publishCallBar("composed", first, callBar("first")) + composition.setContent { TerminalTitleBarBridge.CallBarContent("composed") } + assertEquals("first", value) + TerminalTitleBarBridge.publishCallBar("composed", second, callBar("second")) + composition.setContent { TerminalTitleBarBridge.CallBarContent("composed") } + assertEquals("second", value) + TerminalTitleBarBridge.removeCallBar(second) + composition.setContent { TerminalTitleBarBridge.CallBarContent("composed") } + assertEquals("first", value) + } finally { + composition.dispose() + recomposer.cancel() + TerminalTitleBarBridge.unregisterProvider(first) + TerminalTitleBarBridge.unregisterProvider(second) + TerminalTitleBarBridge.removeCallBar(first) + TerminalTitleBarBridge.removeCallBar(second) + TerminalTitleBarBridge.hostWindow("composed", false) + } + } + + private class EmptyApplier : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun onClear() = Unit + } + + private class EqualOwner { + override fun equals(other: Any?) = other is EqualOwner + override fun hashCode() = 0 + } + + private fun action(id: String) = TerminalTitleBarAction( + id, id, "", ImageVector.Builder(id, 1.dp, 1.dp, 1f, 1f).build(), false, {}, + ) + + @Test + fun `changed publications win and equal owners remain independent`() { + val first = EqualOwner() + val second = EqualOwner() + val a = listOf(action("first")) + val b = listOf(action("second")) + val changed = listOf(action("changed")) + try { + TerminalTitleBarBridge.publish("one", first, true, a) + TerminalTitleBarBridge.publish("one", second, true, b) + assertEquals(b, TerminalTitleBarBridge.actions("one")) + TerminalTitleBarBridge.publish("one", first, true, a) + assertEquals(b, TerminalTitleBarBridge.actions("one"), "unchanged effects do not steal ownership") + TerminalTitleBarBridge.publish("one", first, true, changed) + assertEquals(changed, TerminalTitleBarBridge.actions("one")) + TerminalTitleBarBridge.remove(first) + assertEquals(b, TerminalTitleBarBridge.actions("one")) + TerminalTitleBarBridge.publish("two", second, true, b) + assertTrue(TerminalTitleBarBridge.actions("one").isEmpty()) + assertEquals(b, TerminalTitleBarBridge.actions("two")) + TerminalTitleBarBridge.publish("two", second, false, b) + assertTrue(TerminalTitleBarBridge.actions("two").isEmpty()) + } finally { + TerminalTitleBarBridge.remove(first) + TerminalTitleBarBridge.remove(second) + } + } + + @Test + fun `callbar cleanup is identity scoped and cannot revoke host claim`() { + val first = EqualOwner() + val second = EqualOwner() + try { + TerminalTitleBarBridge.hostWindow("call-window", true) + TerminalTitleBarBridge.publishCallBar("call-window", first) {} + TerminalTitleBarBridge.publishCallBar("call-window", second) {} + TerminalTitleBarBridge.removeCallBar(first) + assertTrue(TerminalTitleBarBridge.hasCallBar("call-window")) + assertFalse(TerminalTitleBarBridge.hasCallBar("other")) + TerminalTitleBarBridge.removeCallBar(second) + assertFalse(TerminalTitleBarBridge.hasCallBar("call-window")) + assertTrue(TerminalTitleBarBridge.isHosted("call-window")) + } finally { + TerminalTitleBarBridge.removeCallBar(first) + TerminalTitleBarBridge.removeCallBar(second) + TerminalTitleBarBridge.hostWindow("call-window", false) + } + assertFalse(TerminalTitleBarBridge.isHosted("call-window")) + } +}