Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions api/boss-plugin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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;
Expand Down
5 changes: 4 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
154 changes: 154 additions & 0 deletions src/main/kotlin/ai/rever/boss/plugin/ui/TerminalTitleBarBridge.kt
Original file line number Diff line number Diff line change
@@ -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<TerminalTitleBarAction>,
)

private data class CallBar(
val windowId: String,
val content: @Composable () -> Unit,
)

private val providers = TerminalOwners<@Composable (String) -> Unit>()
private val callBars = TerminalOwners<CallBar>()
private val entries = TerminalOwners<Entry>()
private val windows = mutableStateMapOf<String, Boolean>()

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<TerminalTitleBarAction>,
) {
entries.put(owner, Entry(windowId, active, actions))
}

fun remove(owner: Any) {
entries.remove(owner)
}

fun actions(windowId: String): List<TerminalTitleBarAction> =
entries
.last { it.windowId == windowId && it.active }
?.value
?.actions
.orEmpty()
}

/** Identity-owned, explicitly ordered state. Unchanged effects do not invalidate composition. */
private class TerminalOwners<T> {
// Wrapper identity also gives Compose key() identity semantics for equal-but-distinct owners.
class Owned<T>(
val owner: Any,
val value: T,
val key: Any = Any(),
)

private val values = mutableStateListOf<Owned<T>>()

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<T>? = values.lastOrNull { predicate(it.value) }
}
124 changes: 124 additions & 0 deletions src/test/kotlin/ai/rever/boss/plugin/ui/TerminalTitleBarBridgeTest.kt
Original file line number Diff line number Diff line change
@@ -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>(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"))
}
}
Loading