Added slash command handling
This commit is contained in:
1 parent
10e9565e2e
commit
1bcadcf950
16 files changed
+567
-21
No files matched your search
@@ -23,6 +23,7 @@ import iris.protocol.mediaUploadStartFrame
|
||||
import iris.protocol.messageSendFrame
|
||||
import iris.protocol.pingFrame
|
||||
import iris.protocol.syncFrame
|
||||
import iris.util.IrisLog
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -198,6 +199,7 @@ class GatewayClient(
|
||||
request,
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
IrisLog.d("ws open (${response.code})")
|
||||
webSocket.send(
|
||||
helloFrame(
|
||||
token = token,
|
||||
@@ -256,10 +258,12 @@ class GatewayClient(
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
IrisLog.w("ws closed code=$code reason=\"$reason\"")
|
||||
closed.complete(Unit)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
IrisLog.e("ws failure: ${t.javaClass.simpleName}: ${t.message} (http=${response?.code})")
|
||||
fail.complete(t.message ?: "connection failed")
|
||||
closed.complete(Unit)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ const val TYPE_CHANNEL_DELETED = "channel.deleted"
|
||||
const val TYPE_CHANNEL_LIST = "channel.list"
|
||||
const val TYPE_SEARCH = "search"
|
||||
const val TYPE_SEARCH_RESULTS = "search.results"
|
||||
|
||||
// Slash-command catalog (the composer's "/" drawer)
|
||||
const val TYPE_COMMANDS_CATALOG = "commands.catalog"
|
||||
const val TYPE_SYNC = "sync"
|
||||
const val TYPE_SYNC_DONE = "sync.done"
|
||||
const val TYPE_HISTORY = "history"
|
||||
@@ -145,6 +148,8 @@ data class ServerCaps(
|
||||
val tools: Boolean = false,
|
||||
val media: Boolean = false,
|
||||
val search: Boolean = false,
|
||||
/** The gateway answers `commands.catalog` (the composer's "/" drawer). */
|
||||
@SerialName("commands_catalog") val commandsCatalog: Boolean = false,
|
||||
val push: String = "fcm",
|
||||
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
||||
val pickers: Boolean = false,
|
||||
@@ -389,6 +394,24 @@ data class SearchResultsPayload(
|
||||
val hits: List<SearchHit> = emptyList(),
|
||||
)
|
||||
|
||||
// ── commands.catalog (slash-command catalog for the "/" drawer) ──────────
|
||||
|
||||
/** One slash command from the gateway catalog. [name]/[aliases] carry the
|
||||
* leading slash ("/new", "/reset"); the app fuzzy-matches client-side. */
|
||||
@Serializable
|
||||
data class SlashCommand(
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
@SerialName("args_hint") val argsHint: String = "",
|
||||
val category: String = "",
|
||||
val aliases: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CommandsCatalogPayload(
|
||||
val commands: List<SlashCommand> = emptyList(),
|
||||
)
|
||||
|
||||
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -594,6 +617,11 @@ fun searchFrame(
|
||||
),
|
||||
)
|
||||
|
||||
/** Request the gateway's slash-command catalog (the composer's "/" drawer).
|
||||
* Answered by a `commands.catalog` frame carrying the same id. */
|
||||
fun commandsCatalogFrame(id: Int): Frame =
|
||||
Frame(id = id, type = TYPE_COMMANDS_CATALOG)
|
||||
|
||||
fun syncFrame(id: Int, cursor: Long): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
|
||||
@@ -15,6 +15,7 @@ import iris.platform.mediaCacheBaseDir
|
||||
import iris.platform.postSystemNotification
|
||||
import iris.protocol.ChannelDeletedPayload
|
||||
import iris.protocol.ChannelInfo
|
||||
import iris.protocol.CommandsCatalogPayload
|
||||
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
|
||||
import iris.protocol.HistoryMessage
|
||||
import iris.protocol.HistoryPayload
|
||||
@@ -27,6 +28,7 @@ import iris.protocol.ReadReceiptPayload
|
||||
import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.SearchHit
|
||||
import iris.protocol.SearchResultsPayload
|
||||
import iris.protocol.SlashCommand
|
||||
import iris.protocol.StatusPayload
|
||||
import iris.protocol.SyncDonePayload
|
||||
import iris.protocol.TYPE_ERROR
|
||||
@@ -36,6 +38,7 @@ import iris.protocol.TYPE_CHANNEL_CREATED
|
||||
import iris.protocol.TYPE_CHANNEL_DELETED
|
||||
import iris.protocol.TYPE_CHANNEL_LIST
|
||||
import iris.protocol.TYPE_CHANNEL_RENAMED
|
||||
import iris.protocol.TYPE_COMMANDS_CATALOG
|
||||
import iris.protocol.TYPE_COMMENTARY
|
||||
import iris.protocol.TYPE_MEDIA_OFFER
|
||||
import iris.protocol.TYPE_MESSAGE
|
||||
@@ -61,6 +64,7 @@ import iris.protocol.channelListFrame
|
||||
import iris.protocol.channelRenameFrame
|
||||
import iris.protocol.channelSetAutomationFrame
|
||||
import iris.protocol.channelSetDefaultFrame
|
||||
import iris.protocol.commandsCatalogFrame
|
||||
import iris.protocol.historyFrame
|
||||
import iris.protocol.messageDeleteFrame
|
||||
import iris.protocol.searchFrame
|
||||
@@ -231,6 +235,13 @@ class IrisController(
|
||||
private val _lastQuery = MutableStateFlow("")
|
||||
val lastQuery: StateFlow<String> = _lastQuery.asStateFlow()
|
||||
|
||||
// ── Slash-command catalog (the composer's "/" drawer) ─────────────────
|
||||
// The gateway's slash commands (commands.catalog). Static per gateway
|
||||
// run; requested on connect and lazily when the drawer opens with an
|
||||
// empty cache. The app fuzzy-matches the typed prefix client-side.
|
||||
private val _slashCommands = MutableStateFlow<List<SlashCommand>>(emptyList())
|
||||
val slashCommands: StateFlow<List<SlashCommand>> = _slashCommands.asStateFlow()
|
||||
|
||||
// ── M4: media ─────────────────────────────────────────────────────────
|
||||
private val mediaCache = MediaCache(mediaCacheBaseDir())
|
||||
|
||||
@@ -397,6 +408,9 @@ class IrisController(
|
||||
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
|
||||
_searching.value = false
|
||||
}
|
||||
TYPE_COMMANDS_CATALOG -> {
|
||||
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
|
||||
}
|
||||
TYPE_SYNC_DONE -> {
|
||||
// Replayed frames already flowed through [events]; the
|
||||
// cursor is authoritative server-side (outbox).
|
||||
@@ -480,6 +494,9 @@ class IrisController(
|
||||
}
|
||||
// M5: a deep link tapped before we were connected.
|
||||
applyDeepLink()
|
||||
// Slash-command catalog for the composer's "/" drawer
|
||||
// (static per gateway run; re-fetched on every (re)connect).
|
||||
requestCommandsCatalog()
|
||||
// Gateway came back after a restart -> announce it (hermes
|
||||
// routine, same icon + wording on all platforms). The core
|
||||
// does not send a startup/online notice to this platform, so
|
||||
@@ -590,6 +607,15 @@ class IrisController(
|
||||
_searching.value = false
|
||||
}
|
||||
|
||||
// ── Slash-command catalog (the composer's "/" drawer) ─────────────────
|
||||
|
||||
/** Request the gateway's slash-command catalog. No-op while disconnected
|
||||
* (sendFrame drops silently); the response lands via [slashCommands]. */
|
||||
fun requestCommandsCatalog() {
|
||||
if (client.state.value !is GatewayClient.State.Connected) return
|
||||
client.sendFrame(commandsCatalogFrame(0))
|
||||
}
|
||||
|
||||
// ── M3: sync (reconnect catch-up) ─────────────────────────────────────
|
||||
|
||||
fun sync(cursor: Long) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -122,6 +124,7 @@ import iris.protocol.KIND_IMAGE
|
||||
import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.ROLE_USER
|
||||
import iris.protocol.SearchHit
|
||||
import iris.protocol.SlashCommand
|
||||
import iris.state.IrisController
|
||||
import iris.state.ToolDetail
|
||||
import iris.ui.MarkdownText
|
||||
@@ -132,6 +135,7 @@ import iris.ui.theme.avatarColor
|
||||
import iris.ui.theme.contrastText
|
||||
import iris.util.formatDayLabel
|
||||
import iris.util.formatTime
|
||||
import iris.util.fuzzyScore
|
||||
import iris.util.localDayKey
|
||||
import iris.util.prepareForMarkdown
|
||||
import iris.util.preserveNewlinesAsHardBreaks
|
||||
@@ -256,6 +260,37 @@ fun ChatScreen(controller: IrisController) {
|
||||
if (idx >= 0) railIndex = idx
|
||||
}
|
||||
|
||||
// Slash-command drawer (the composer's "/" autocomplete): while the input
|
||||
// is a bare "/…" (no space yet), fuzzy-match the typed prefix against the
|
||||
// gateway catalog. Tapping a row sends the command; the drawer closes
|
||||
// when the input leaves the "/" form, the match list empties (unknown
|
||||
// command), or a command is sent.
|
||||
val slashCommands by controller.slashCommands.collectAsState()
|
||||
val slashQuery =
|
||||
if (input.startsWith("/") && !input.contains(" ")) input.removePrefix("/") else null
|
||||
val slashMatches = remember(slashQuery, slashCommands) {
|
||||
if (slashQuery == null) emptyList()
|
||||
else slashCommands
|
||||
.map { it to bestSlashScore(slashQuery, it) }
|
||||
.filter { it.second >= 0 }
|
||||
.sortedWith(compareBy({ it.second }, { it.first.name }))
|
||||
.map { it.first }
|
||||
}
|
||||
val slashDrawerOpen = slashQuery != null && slashMatches.isNotEmpty()
|
||||
|
||||
// The catalog is fetched on connect; if the drawer opens before the first
|
||||
// response lands (or the gateway predates the frame), fetch it lazily.
|
||||
LaunchedEffect(slashQuery) {
|
||||
if (slashQuery != null && slashCommands.isEmpty()) controller.requestCommandsCatalog()
|
||||
}
|
||||
|
||||
fun onSlashPick(cmd: SlashCommand) {
|
||||
if (state !is GatewayClient.State.Connected) return
|
||||
input = ""
|
||||
controller.send(cmd.name)
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
|
||||
// M6: keyboard shortcuts (docs/11 §11.3) — desktop only.
|
||||
val shortcutsModifier = if (isDesktop) {
|
||||
Modifier.onPreviewKeyEvent { e ->
|
||||
@@ -272,6 +307,8 @@ fun ChatScreen(controller: IrisController) {
|
||||
}
|
||||
ctrlOrMeta && e.key == Key.F -> { showSearch = true; true }
|
||||
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
|
||||
// Escape closes the slash drawer (clears the "/" input).
|
||||
e.key == Key.Escape && slashDrawerOpen -> { input = ""; true }
|
||||
e.key == Key.Escape && overlayOpen -> {
|
||||
showSearch = false
|
||||
showPicker = false
|
||||
@@ -550,6 +587,67 @@ fun ChatScreen(controller: IrisController) {
|
||||
}
|
||||
}
|
||||
|
||||
// Slash-command drawer: rolls up over the composer while the input is a
|
||||
// bare "/…". Fuzzy-matched against the gateway catalog; tapping a
|
||||
// row sends the command (and closes the drawer).
|
||||
AnimatedVisibility(
|
||||
visible = slashDrawerOpen && !isAutomation && !selectionMode,
|
||||
enter = fadeIn(tween(120)) + slideInVertically(tween(160)) { it / 3 },
|
||||
exit = fadeOut(tween(100)) + slideOutVertically(tween(120)) { it / 3 },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(IrisColors.panel)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)),
|
||||
) {
|
||||
slashMatches.take(SLASH_DRAWER_MAX).forEachIndexed { index, cmd ->
|
||||
if (index > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(IrisColors.divider),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSlashPick(cmd) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
cmd.name,
|
||||
color = IrisColors.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
if (cmd.argsHint.isNotEmpty()) {
|
||||
Text(
|
||||
" ${cmd.argsHint}",
|
||||
color = IrisColors.textTertiary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
cmd.description,
|
||||
color = IrisColors.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selection toolbar (replaces the composer while messages are selected).
|
||||
if (selectionMode) {
|
||||
SelectionToolbar(
|
||||
@@ -2429,4 +2527,20 @@ internal val ToolDetail.label: String
|
||||
ToolDetail.EVERYTHING -> "everything"
|
||||
ToolDetail.TRUNCATED -> "truncated"
|
||||
ToolDetail.NOTHING -> "nothing"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slash-command drawer ──────────────────────────────────────────────────
|
||||
|
||||
/** Max rows in the slash drawer (the rest is reachable by typing more). */
|
||||
private const val SLASH_DRAWER_MAX = 8
|
||||
|
||||
/** Best fuzzy rank of [query] against a command's name or any of its
|
||||
* aliases; -1 when nothing matches. */
|
||||
private fun bestSlashScore(query: String, cmd: SlashCommand): Int {
|
||||
var best = fuzzyScore(query, cmd.name.removePrefix("/"))
|
||||
for (alias in cmd.aliases) {
|
||||
val s = fuzzyScore(query, alias.removePrefix("/"))
|
||||
if (s >= 0 && (best < 0 || s < best)) best = s
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package iris.util
|
||||
|
||||
/**
|
||||
* Fuzzy subsequence scoring for the slash-command drawer.
|
||||
*
|
||||
* [fuzzyScore] returns a non-negative rank (lower = better) when every
|
||||
* character of [query] appears in [target] in order (case-insensitive), or
|
||||
* -1 when it does not. Prefix matches rank best (1); a consecutive run of
|
||||
* matched characters ranks better than a gapped one, so "mod" beats "m...d"
|
||||
* for "/model".
|
||||
*/
|
||||
fun fuzzyScore(query: String, target: String): Int {
|
||||
val q = query.lowercase()
|
||||
if (q.isEmpty()) return 0
|
||||
val t = target.lowercase()
|
||||
if (t.startsWith(q)) return 1
|
||||
var qi = 0
|
||||
var score = 0
|
||||
var prevMatch = -2
|
||||
for (i in t.indices) {
|
||||
if (t[i] == q[qi]) {
|
||||
score += if (prevMatch == i - 1) 1 else 4
|
||||
prevMatch = i
|
||||
qi++
|
||||
if (qi == q.length) return score
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package iris.util
|
||||
|
||||
/** Minimal logging. On Android, stdout goes to logcat; on desktop it goes to
|
||||
* the console. Kept dependency-free (common stdlib only). */
|
||||
object IrisLog {
|
||||
private const val TAG = "Iris"
|
||||
|
||||
fun d(msg: String) = println("$TAG: $msg")
|
||||
|
||||
fun w(msg: String) = println("$TAG: $msg")
|
||||
|
||||
fun e(msg: String) = println("$TAG: $msg")
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FuzzyTest {
|
||||
@Test
|
||||
fun emptyQueryMatchesEverything() {
|
||||
assertEquals(0, fuzzyScore("", "new"))
|
||||
assertEquals(0, fuzzyScore("", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefixMatchRanksBest() {
|
||||
assertEquals(1, fuzzyScore("mod", "model"))
|
||||
assertEquals(1, fuzzyScore("NEW", "new"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subsequenceMatchesInOrder() {
|
||||
assertTrue(fuzzyScore("nw", "new") >= 0)
|
||||
assertTrue(fuzzyScore("mdl", "model") >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outOfOrderDoesNotMatch() {
|
||||
assertEquals(-1, fuzzyScore("wn", "new"))
|
||||
assertEquals(-1, fuzzyScore("xyz", "model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consecutiveRunBeatsGapped() {
|
||||
// "sta" is a consecutive run in "status"; "stu" is gapped in "status".
|
||||
assertTrue(fuzzyScore("sta", "status") < fuzzyScore("stu", "status"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefixBeatsSubsequence() {
|
||||
assertTrue(fuzzyScore("mod", "model") < fuzzyScore("mdl", "model"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user