From 275811a5292bdb0c9fb3dc629cd4960c08b22d3b Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 00:06:41 +0300 Subject: [PATCH 01/42] feat: add herdr multiplexer support Adds herdr (https://herdr.dev) as a runtime-supported multiplexer type alongside tmux and zmx, following the generic Multiplexer abstraction: availability detection, JSON session listing, attach-or-create launch, and attach-only guard with graceful shell fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../jossephus/chuchu/model/MultiplexerType.kt | 1 + .../service/multiplexer/HerdrMultiplexer.kt | 77 ++++++++++++ .../multiplexer/MultiplexerRegistry.kt | 1 + .../ui/screens/AddServer/AddServerScreen.kt | 2 + .../multiplexer/HerdrMultiplexerTest.kt | 115 ++++++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt create mode 100644 android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/model/MultiplexerType.kt b/android/app/src/main/java/com/jossephus/chuchu/model/MultiplexerType.kt index 5622d312..64da3912 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/model/MultiplexerType.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/model/MultiplexerType.kt @@ -8,6 +8,7 @@ enum class MultiplexerType( Tmux("tmux", "tmux", true), Zellij("zellij", "zellij", false), Zmx("zmx", "zmx", true), + Herdr("herdr", "herdr", true), ; companion object { diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt new file mode 100644 index 00000000..c27614f2 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -0,0 +1,77 @@ +package com.jossephus.chuchu.service.multiplexer + +import com.jossephus.chuchu.model.MultiplexerType +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +object HerdrMultiplexer : Multiplexer { + private const val pathPrelude = "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + override val type: MultiplexerType = MultiplexerType.Herdr + + override fun availabilityCommand(): String = pathPrelude + "command -v herdr >/dev/null 2>&1" + + override fun listSessionsCommand(): String = + pathPrelude + "if ! command -v herdr >/dev/null 2>&1; then printf 'herdr executable not found\\n' >&2; false; " + + "else herdr session list --json 2>/dev/null; fi" + + override fun parseSessions(output: String): List { + val jsonStart = output.indexOf('{') + if (jsonStart < 0) return emptyList() + return try { + json.decodeFromString(output.substring(jsonStart)) + .sessions + .asSequence() + .filter { it.name.isNotBlank() } + .sortedByDescending { it.default } + .map { session -> + RemoteMultiplexerSession( + name = session.name, + attached = session.running, + ) + } + .toList() + } catch (_: Exception) { + emptyList() + } + } + + override fun launchCommand( + sessionName: String, + createIfMissing: Boolean, + trustedRemoteName: Boolean, + ): String { + val target = shellQuote(sessionName) + return if (createIfMissing) { + pathPrelude + "exec herdr --session $target" + } else { + val pattern = shellQuote("\"name\":\"$sessionName\"") + pathPrelude + "if herdr session list --json 2>/dev/null | grep -Fq -- $pattern; then exec herdr session attach $target; " + + "else printf 'herdr session %s is no longer available\\n' $target; exec \"\${SHELL:-/bin/sh}\" -l; fi" + } + } + + override fun defaultSessionName( + remoteSessions: Collection, + localSessionNames: Collection, + ): String = "default" + + private fun shellQuote(value: String): String = + "'" + value.replace("'", "'\\''") + "'" + + @Serializable + private data class HerdrSessionList( + val sessions: List = emptyList(), + ) + + @Serializable + private data class HerdrSession( + val default: Boolean = false, + val name: String = "", + val running: Boolean = false, + ) +} diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/MultiplexerRegistry.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/MultiplexerRegistry.kt index cd198255..ab65a5fa 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/MultiplexerRegistry.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/MultiplexerRegistry.kt @@ -8,6 +8,7 @@ object MultiplexerRegistry { fun forType(type: MultiplexerType): Multiplexer? = when (type) { MultiplexerType.Tmux -> TmuxMultiplexer MultiplexerType.Zmx -> ZmxMultiplexer + MultiplexerType.Herdr -> HerdrMultiplexer MultiplexerType.Zellij -> null } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt index dd849714..be84c3d8 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt @@ -389,6 +389,7 @@ private enum class MultiplexerOption(val type: MultiplexerType?) { Tmux(MultiplexerType.Tmux), Zellij(MultiplexerType.Zellij), Zmx(MultiplexerType.Zmx), + Herdr(MultiplexerType.Herdr), } @Composable @@ -408,6 +409,7 @@ private fun MultiplexerSection( MultiplexerOption.Tmux to "tmux", MultiplexerOption.Zellij to "zellij", MultiplexerOption.Zmx to "zmx", + MultiplexerOption.Herdr to "herdr", ), selected = selectedOption, onSelect = { onSelect(it.type) }, diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt new file mode 100644 index 00000000..724fd8d2 --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt @@ -0,0 +1,115 @@ +package com.jossephus.chuchu.service.multiplexer + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HerdrMultiplexerTest { + @Test + fun availabilityCommandChecksExecutable() { + val command = HerdrMultiplexer.availabilityCommand() + + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; command -v herdr >/dev/null 2>&1", + command, + ) + } + + @Test + fun listSessionsCommandChecksExecutable() { + val command = HerdrMultiplexer.listSessionsCommand() + + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + + "if ! command -v herdr >/dev/null 2>&1; then printf 'herdr executable not found\\n' >&2; false; " + + "else herdr session list --json 2>/dev/null; fi", + command, + ) + } + + @Test + fun parsesSessionList() { + val sessions = HerdrMultiplexer.parseSessions( + "{\"sessions\":[{\"default\":true,\"name\":\"default\",\"running\":true," + + "\"session_dir\":\"/home/salem/.config/herdr\",\"socket_path\":\"/home/salem/.config/herdr/herdr.sock\"}]}", + ) + + assertEquals( + listOf(RemoteMultiplexerSession(name = "default", attached = true)), + sessions, + ) + } + + @Test + fun parsesDefaultSessionFirst() { + val sessions = HerdrMultiplexer.parseSessions( + "{\"sessions\":[{\"default\":false,\"name\":\"work\",\"running\":false}," + + "{\"default\":true,\"name\":\"default\",\"running\":true}]}", + ) + + assertEquals( + listOf( + RemoteMultiplexerSession(name = "default", attached = true), + RemoteMultiplexerSession(name = "work", attached = false), + ), + sessions, + ) + } + + @Test + fun parsesEmptyListForInvalidOutput() { + assertEquals(emptyList(), HerdrMultiplexer.parseSessions("garbage")) + assertEquals(emptyList(), HerdrMultiplexer.parseSessions("")) + assertEquals( + listOf(RemoteMultiplexerSession(name = "default", attached = true)), + HerdrMultiplexer.parseSessions("Last login: yesterday\\n{\"sessions\":[{\"default\":true,\"name\":\"default\",\"running\":true}]}"), + ) + } + + @Test + fun launchCreateUsesSessionUpsert() { + val command = HerdrMultiplexer.launchCommand( + sessionName = "default", + createIfMissing = true, + trustedRemoteName = false, + ) + + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; exec herdr --session 'default'", + command, + ) + } + + @Test + fun launchExistingChecksListBeforeAttach() { + val command = HerdrMultiplexer.launchCommand( + sessionName = "main", + createIfMissing = false, + trustedRemoteName = true, + ) + + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + + "if herdr session list --json 2>/dev/null | grep -Fq -- '\"name\":\"main\"'; " + + "then exec herdr session attach 'main'; else printf 'herdr session %s is no longer available\\n' 'main'; " + + "exec \"\${SHELL:-/bin/sh}\" -l; fi", + command, + ) + } + + @Test + fun launchQuotesSessionNames() { + val command = HerdrMultiplexer.launchCommand( + sessionName = "work';rm -rf", + createIfMissing = false, + trustedRemoteName = true, + ) + + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + + "if herdr session list --json 2>/dev/null | grep -Fq -- '\"name\":\"work'\\'';rm -rf\"'; " + + "then exec herdr session attach 'work'\\'';rm -rf'; else printf 'herdr session %s is no longer available\\n' " + + "'work'\\'';rm -rf'; exec \"\${SHELL:-/bin/sh}\" -l; fi", + command, + ) + } +} From 9f6d66c0aa6d04585006557e99aa4d2a1de6da6f Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 00:10:27 +0300 Subject: [PATCH 02/42] feat: add herdr snapshot models and parser Serializable models for the herdr api snapshot document (workspaces, tabs, panes with agent status) with a lenient agent-status decoder that maps unknown values to Unknown for protocol forward-compatibility. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrSnapshot.kt | 127 ++++++++++++++++++ .../multiplexer/HerdrSnapshotParserTest.kt | 84 ++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt create mode 100644 android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt new file mode 100644 index 00000000..100ceead --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt @@ -0,0 +1,127 @@ +package com.jossephus.chuchu.service.multiplexer + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +@Serializable +data class HerdrSnapshot( + val protocol: Int = 0, + val version: String = "", + val workspaces: List = emptyList(), + val tabs: List = emptyList(), + val panes: List = emptyList(), + @SerialName("focused_workspace_id") val focusedWorkspaceId: String? = null, + @SerialName("focused_tab_id") val focusedTabId: String? = null, + @SerialName("focused_pane_id") val focusedPaneId: String? = null, +) + +@Serializable +data class HerdrWorkspace( + @SerialName("workspace_id") val workspaceId: String = "", + val label: String? = null, + val number: Int = 0, + @SerialName("agent_status") val agentStatus: HerdrAgentStatus = HerdrAgentStatus.Unknown, + val focused: Boolean = false, + @SerialName("tab_count") val tabCount: Int = 0, + @SerialName("pane_count") val paneCount: Int = 0, + @SerialName("active_tab_id") val activeTabId: String? = null, +) + +@Serializable +data class HerdrTab( + @SerialName("tab_id") val tabId: String = "", + @SerialName("workspace_id") val workspaceId: String = "", + val label: String? = null, + val number: Int = 0, + @SerialName("agent_status") val agentStatus: HerdrAgentStatus = HerdrAgentStatus.Unknown, + val focused: Boolean = false, + @SerialName("pane_count") val paneCount: Int = 0, +) + +@Serializable +data class HerdrPane( + @SerialName("pane_id") val paneId: String = "", + @SerialName("tab_id") val tabId: String = "", + @SerialName("workspace_id") val workspaceId: String = "", + val agent: String? = null, + @SerialName("agent_status") val agentStatus: HerdrAgentStatus = HerdrAgentStatus.Unknown, + val cwd: String? = null, + @SerialName("terminal_title_stripped") val terminalTitleStripped: String? = null, + val focused: Boolean = false, +) + +@Serializable(with = HerdrAgentStatusSerializer::class) +enum class HerdrAgentStatus { + Idle, + Working, + Blocked, + Done, + Unknown, +} + +object HerdrAgentStatusSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("HerdrAgentStatus", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): HerdrAgentStatus { + val element = (decoder as? JsonDecoder)?.decodeJsonElement() + val value = (element as? JsonPrimitive)?.contentOrNull + return when (value) { + "idle" -> HerdrAgentStatus.Idle + "working" -> HerdrAgentStatus.Working + "blocked" -> HerdrAgentStatus.Blocked + "done" -> HerdrAgentStatus.Done + else -> HerdrAgentStatus.Unknown + } + } + + override fun serialize(encoder: Encoder, value: HerdrAgentStatus) { + encoder.encodeString( + when (value) { + HerdrAgentStatus.Idle -> "idle" + HerdrAgentStatus.Working -> "working" + HerdrAgentStatus.Blocked -> "blocked" + HerdrAgentStatus.Done -> "done" + HerdrAgentStatus.Unknown -> "unknown" + }, + ) + } +} + +private val herdrSnapshotJson = Json { + ignoreUnknownKeys = true + isLenient = true +} + +fun parseHerdrSnapshot(output: String): HerdrSnapshot? { + val jsonStart = output.indexOf('{') + if (jsonStart < 0) return null + + return try { + val envelope = herdrSnapshotJson.decodeFromString(output.substring(jsonStart)) + if (envelope.id != "cli:api:snapshot") null else envelope.result.snapshot + } catch (_: Exception) { + null + } +} + +@Serializable +private data class HerdrSnapshotEnvelope( + val id: String, + val result: HerdrSnapshotResult, +) + +@Serializable +private data class HerdrSnapshotResult( + val snapshot: HerdrSnapshot, +) diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt new file mode 100644 index 00000000..cd00fa4a --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt @@ -0,0 +1,84 @@ +package com.jossephus.chuchu.service.multiplexer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class HerdrSnapshotParserTest { + @Test + fun parsesRealSnapshotFixture() { + val snapshot = parseHerdrSnapshot(realSnapshotFixture) + + assertNotNull(snapshot) + snapshot!! + assertEquals(16, snapshot.protocol) + assertEquals(1, snapshot.workspaces.size) + assertEquals(2, snapshot.tabs.size) + assertEquals(2, snapshot.panes.size) + + val pane = snapshot.panes.first { it.paneId == "wJ:p5Y" } + assertEquals("pi", pane.agent) + assertEquals(HerdrAgentStatus.Working, pane.agentStatus) + assertEquals("/home/salem/coding/ERBareeq", pane.cwd) + assertEquals("π - ERBareeq: safely implement Postgres migration scope - ERBareeq", pane.terminalTitleStripped) + + assertEquals("wJ", snapshot.focusedWorkspaceId) + assertEquals("wJ:t1H", snapshot.focusedTabId) + assertEquals("wJ:p5Y", snapshot.focusedPaneId) + } + + @Test + fun mapsKnownAndUnknownAgentStatuses() { + val snapshot = parseHerdrSnapshot( + """ + {"id":"cli:api:snapshot","result":{"snapshot":{"panes":[ + {"pane_id":"idle","agent_status":"idle"}, + {"pane_id":"working","agent_status":"working"}, + {"pane_id":"blocked","agent_status":"blocked"}, + {"pane_id":"done","agent_status":"done"}, + {"pane_id":"future","agent_status":"garbage-future-status"}, + {"pane_id":"missing"} + ]}}} + """.trimIndent(), + )!! + + assertEquals( + listOf( + HerdrAgentStatus.Idle, + HerdrAgentStatus.Working, + HerdrAgentStatus.Blocked, + HerdrAgentStatus.Done, + HerdrAgentStatus.Unknown, + HerdrAgentStatus.Unknown, + ), + snapshot.panes.map { it.agentStatus }, + ) + } + + @Test + fun parsesNoisePrefixedSnapshotFixture() { + val expected = parseHerdrSnapshot(realSnapshotFixture) + val actual = parseHerdrSnapshot("Welcome!\nmotd line\n$realSnapshotFixture") + + assertEquals(expected, actual) + } + + @Test + fun returnsNullForInvalidOutput() { + assertNull(parseHerdrSnapshot("")) + assertNull(parseHerdrSnapshot("not json")) + assertNull(parseHerdrSnapshot(realSnapshotFixture.dropLast(1))) + } + + @Test + fun returnsNullForMissingEnvelopeFields() { + assertNull(parseHerdrSnapshot("{\"id\":\"cli:api:snapshot\"}")) + assertNull(parseHerdrSnapshot("{\"id\":\"cli:api:snapshot\",\"result\":{}}")) + } + + private companion object { + val realSnapshotFixture = + """{"id":"cli:api:snapshot","result":{"snapshot":{"agents":[{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"/home/salem/.pi/agent/sessions/--home-salem-coding-ERBareeq--/2026-07-16T07-13-22-703Z_019f69c5-dd0f-7da2-a8c5-559c1d548757.jsonl"},"agent_status":"idle","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p3D","revision":10,"screen_detection_skipped":true,"tab_id":"wJ:tR","terminal_id":"term_656acc0585f9a2","terminal_title":"\u03c0 - ERBareeq: MSSQL Sync Layer Refactor - ERBareeq","terminal_title_stripped":"\u03c0 - ERBareeq: MSSQL Sync Layer Refactor - ERBareeq","workspace_id":"wJ"},{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"/home/salem/.pi/agent/sessions/--home-salem-coding-ERBareeq--/2026-07-18T21-36-52-186Z_019f7729-21da-79d6-833b-6af0d72af2cb.jsonl"},"agent_status":"idle","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p52","revision":16,"screen_detection_skipped":true,"tab_id":"wJ:t16","terminal_id":"term_656e384d6906144","terminal_title":"\u03c0 - ERBareeq: Fix Bareeq Chat UI Papercuts - ERBareeq","terminal_title_stripped":"\u03c0 - ERBareeq: Fix Bareeq Chat UI Papercuts - ERBareeq","tokens":{"pi_session_name":"ERBareeq: Fix Bareeq Chat UI Papercuts"},"workspace_id":"wJ"}],"focused_pane_id":"wJ:p5Y","focused_tab_id":"wJ:t1H","focused_workspace_id":"wJ","layouts":[],"panes":[{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"/home/salem/.pi/agent/sessions/--home-salem-coding-ERBareeq--/2026-07-16T07-13-22-703Z_019f69c5-dd0f-7da2-a8c5-559c1d548757.jsonl"},"agent_status":"idle","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p3D","revision":10,"scroll":{"max_offset_from_bottom":535,"offset_from_bottom":0,"viewport_rows":56},"tab_id":"wJ:tR","terminal_id":"term_656acc0585f9a2","terminal_title":"\u03c0 - ERBareeq: MSSQL Sync Layer Refactor - ERBareeq","terminal_title_stripped":"\u03c0 - ERBareeq: MSSQL Sync Layer Refactor - ERBareeq","workspace_id":"wJ"},{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"/home/salem/.pi/agent/sessions/--home-salem-coding-ERBareeq--/2026-07-19T19-19-42-452Z_019f7bd1-ea74-79d0-ae10-d4306bddc29c.jsonl"},"agent_status":"working","cwd":"/home/salem/coding/ERBareeq","focused":true,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p5Y","revision":9,"scroll":{"max_offset_from_bottom":670,"offset_from_bottom":0,"viewport_rows":54},"tab_id":"wJ:t1H","terminal_id":"term_656fad1f3c60b63","terminal_title":"\u03c0 - ERBareeq: safely implement Postgres migration scope - ERBareeq","terminal_title_stripped":"\u03c0 - ERBareeq: safely implement Postgres migration scope - ERBareeq","tokens":{"pi_session_name":"ERBareeq: safely implement Postgres migration scope"},"workspace_id":"wJ"}],"protocol":16,"tabs":[{"agent_status":"idle","focused":false,"label":"1","number":24,"pane_count":1,"tab_id":"wJ:tR","workspace_id":"wJ"},{"agent_status":"working","focused":true,"label":"3","number":49,"pane_count":3,"tab_id":"wJ:t1H","workspace_id":"wJ"}],"version":"0.7.4","workspaces":[{"active_tab_id":"wJ:t1H","agent_status":"working","focused":true,"label":"ERBareeq","number":1,"pane_count":5,"tab_count":3,"workspace_id":"wJ"}]}}}""" + } +} From 5a14b66523791fba8d8970128b07dd5433e0e827 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 00:19:02 +0300 Subject: [PATCH 03/42] feat: add herdr control plane to terminal session engine Streams herdr api snapshots over a dedicated client-paced SSH exec channel (one connection, newline-per-poll), with idle backoff, poke on mutation, exponential retry, and teardown on disconnect/reconnect. Adds focus/create/close control verbs via the existing exec command envelope. Also replaces Thread.sleep with cancellable delay in readExecOutput. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrMultiplexer.kt | 19 +- .../service/multiplexer/HerdrSnapshot.kt | 39 ++++ .../service/terminal/TerminalSessionEngine.kt | 187 +++++++++++++++++- .../multiplexer/HerdrStreamFramingTest.kt | 99 ++++++++++ 4 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrStreamFramingTest.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt index c27614f2..27004e66 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json object HerdrMultiplexer : Multiplexer { - private const val pathPrelude = "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + internal const val pathPrelude = "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " private val json = Json { ignoreUnknownKeys = true isLenient = true @@ -60,6 +60,23 @@ object HerdrMultiplexer : Multiplexer { localSessionNames: Collection, ): String = "default" + fun snapshotStreamCommand(): String = + pathPrelude + + "while IFS= read -r _; do printf 'CHUCHU_SNAP_BEGIN\\n'; herdr api snapshot 2>/dev/null; " + + "printf '\\nCHUCHU_SNAP_END\\n'; done" + + fun focusTabCommand(tabId: String): String = + pathPrelude + "herdr tab focus ${shellQuote(tabId)}" + + fun focusPaneCommand(paneId: String): String = + pathPrelude + "herdr agent focus ${shellQuote(paneId)}" + + fun createTabCommand(workspaceId: String): String = + pathPrelude + "herdr tab create --workspace ${shellQuote(workspaceId)} --focus" + + fun closeTabCommand(tabId: String): String = + pathPrelude + "herdr tab close ${shellQuote(tabId)}" + private fun shellQuote(value: String): String = "'" + value.replace("'", "'\\''") + "'" diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt index 100ceead..54931115 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt @@ -25,6 +25,45 @@ data class HerdrSnapshot( @SerialName("focused_pane_id") val focusedPaneId: String? = null, ) +sealed interface HerdrControlState { + data object Inactive : HerdrControlState + + data object Connecting : HerdrControlState + + data class Active( + val snapshot: HerdrSnapshot, + val protocolWarning: Boolean, + ) : HerdrControlState + + data class Error(val message: String) : HerdrControlState +} + +internal fun appendHerdrStreamChunk(buffer: StringBuilder, chunk: String): List { + val begin = "CHUCHU_SNAP_BEGIN\n" + val end = "\nCHUCHU_SNAP_END" + val frames = mutableListOf() + buffer.append(chunk) + + while (true) { + val beginIndex = buffer.indexOf(begin) + if (beginIndex < 0) { + val keep = + (begin.length - 1 downTo 1).firstOrNull { length -> + buffer.endsWith(begin.substring(0, length)) + } ?: 0 + if (buffer.length > keep) buffer.delete(0, buffer.length - keep) + return frames + } + if (beginIndex > 0) buffer.delete(0, beginIndex) + + val endIndex = buffer.indexOf(end, begin.length) + if (endIndex < 0) return frames + + frames += buffer.substring(begin.length, endIndex) + buffer.delete(0, endIndex + end.length) + } +} + @Serializable data class HerdrWorkspace( @SerialName("workspace_id") val workspaceId: String = "", diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index a1a9b8d5..997a9b5b 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -9,10 +9,15 @@ import com.jossephus.chuchu.service.mosh.MoshBootstrapParser import com.jossephus.chuchu.service.mosh.MoshEventType import com.jossephus.chuchu.service.mosh.MoshState import com.jossephus.chuchu.service.mosh.NativeMoshService +import com.jossephus.chuchu.service.multiplexer.HerdrControlState +import com.jossephus.chuchu.service.multiplexer.HerdrMultiplexer +import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot import com.jossephus.chuchu.service.multiplexer.MultiplexerAvailability import com.jossephus.chuchu.service.multiplexer.MultiplexerCommandResult import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry import com.jossephus.chuchu.service.multiplexer.RemoteMultiplexerSession +import com.jossephus.chuchu.service.multiplexer.appendHerdrStreamChunk +import com.jossephus.chuchu.service.multiplexer.parseHerdrSnapshot import com.jossephus.chuchu.service.ssh.HostKeyStore import com.jossephus.chuchu.service.ssh.NativeSshService import com.jossephus.chuchu.service.ssh.TailscaleStatusChecker @@ -24,6 +29,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -33,6 +39,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject enum class SessionStatus { @@ -129,6 +136,11 @@ class TerminalSessionEngine( private var lastConnectionParams: ConnectionParams? = null private var reconnectJob: Job? = null private var disconnectRequested = false + private var herdrStreamJob: Job? = null + @Volatile private var herdrStreamService: NativeSshService? = null + private val herdrPoke = Channel(Channel.CONFLATED) + private val herdrCadenceMs = MutableStateFlow(3_000L) + @Volatile private var herdrIdleStretch = 0 private val nativeVersion = @@ -141,6 +153,9 @@ class TerminalSessionEngine( private val _state = MutableStateFlow(SessionState(nativeVersion = nativeVersion)) val state: StateFlow = _state.asStateFlow() + private val _herdrState = MutableStateFlow(HerdrControlState.Inactive) + val herdrState: StateFlow = _herdrState.asStateFlow() + data class DefaultColors( val fg: IntArray?, val bg: IntArray?, @@ -169,6 +184,7 @@ class TerminalSessionEngine( multiplexerCreateIfMissing: Boolean = true, ) { disconnectRequested = false + stopHerdrStream() val params = ConnectionParams( host = host, @@ -263,6 +279,7 @@ class TerminalSessionEngine( requestSnapshot(force = true) startReadLoop() sendStartupCommand(params) + if (isHerdrSshSession(params)) startHerdrStream(params) } catch (e: LinkageError) { Log.e("TerminalSession", "Connect failed", e) _state.value = @@ -476,7 +493,7 @@ class TerminalSessionEngine( suspend fun checkMultiplexerAvailability(spec: TabSpec): MultiplexerAvailability = withContext(dispatcher) { checkMultiplexerAvailability(spec.toConnectionParams()) } - private fun checkMultiplexerAvailability(params: ConnectionParams): MultiplexerAvailability { + private suspend fun checkMultiplexerAvailability(params: ConnectionParams): MultiplexerAvailability { val type = params.multiplexer ?: return MultiplexerAvailability.Available val multiplexer = MultiplexerRegistry.forType(type) ?: return MultiplexerAvailability.UnsupportedMultiplexer(type) @@ -559,10 +576,33 @@ class TerminalSessionEngine( multiplexer.defaultSessionName(remoteSessions, localSessionNames) } + fun setHerdrCadence(ms: Long) { + herdrCadenceMs.value = ms.coerceIn(1_000L, 60_000L) + pokeHerdr() + } + + fun pokeHerdr() { + herdrIdleStretch = 0 + herdrPoke.trySend(Unit) + } + + suspend fun herdrFocusTab(tabId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.focusTabCommand(tabId) } + + suspend fun herdrFocusPane(paneId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.focusPaneCommand(paneId) } + + suspend fun herdrCreateTab(workspaceId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.createTabCommand(workspaceId) } + + suspend fun herdrCloseTab(tabId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.closeTabCommand(tabId) } + fun disconnect() { disconnectRequested = true reconnectJob?.cancel() reconnectJob = null + stopHerdrStream() lastConnectionParams = null cancelHostKeyPrompt() scope.launch(dispatcher) { @@ -955,7 +995,7 @@ class TerminalSessionEngine( multiplexerCreateIfMissing = multiplexerCreateIfMissing, ) - private fun runMultiplexerCommand( + private suspend fun runMultiplexerCommand( params: ConnectionParams, command: String, timeoutMs: Long = 20_000, @@ -982,7 +1022,7 @@ class TerminalSessionEngine( private fun withExitEnvelope(command: String): String = "$command; printf '\nCHUCHU_EXIT:%s\n' \"\$?\"" - private fun readExecOutput( + private suspend fun readExecOutput( service: NativeSshService, timeoutMs: Long, ): MultiplexerCommandResult { @@ -995,12 +1035,149 @@ class TerminalSessionEngine( } else if (service.isChannelEof()) { return parseCommandEnvelope(output.toString()) } else { - Thread.sleep(25) + delay(25) } } return MultiplexerCommandResult(124, output.toString(), "Command timed out") } + private suspend fun runHerdrControl( + command: () -> String, + ): MultiplexerCommandResult = + withContext(dispatcher) { + val params = lastConnectionParams + if (params == null || !isHerdrSshSession(params)) { + return@withContext MultiplexerCommandResult( + exitCode = 1, + stdout = "", + stderr = "Herdr control is only available for an active Herdr SSH session", + ) + } + val result = runMultiplexerCommand(params, command()) + pokeHerdr() + result + } + + private fun isHerdrSshSession(params: ConnectionParams): Boolean = + params.multiplexer == MultiplexerType.Herdr && + (params.transport == Transport.SSH || params.transport == Transport.TailscaleSSH) + + private fun isHerdrStreamCurrent(params: ConnectionParams): Boolean = + !disconnectRequested && lastConnectionParams == params && _state.value.status == SessionStatus.Connected + + private fun startHerdrStream(params: ConnectionParams) { + stopHerdrStream() + if (!isHerdrSshSession(params)) return + herdrStreamJob = + scope.launch(Dispatchers.IO) { + var retryDelayMs = 2_000L + while (currentCoroutineContext().isActive && isHerdrStreamCurrent(params)) { + _herdrState.value = HerdrControlState.Connecting + var service: NativeSshService? = null + var failed = false + try { + service = NativeSshService(hostKeyPolicy = ::verifyHostKey) + herdrStreamService = service + service.connect( + host = params.host, + port = params.port, + username = params.username, + authMethod = params.authMethod, + password = if (params.authMethod == AuthMethod.Password) params.password else "", + publicKeyOpenSsh = params.publicKeyOpenSsh, + privateKeyPem = params.privateKeyPem, + keyPassphrase = params.keyPassphrase, + ) + if (!service.openExec(HerdrMultiplexer.snapshotStreamCommand())) { + throw IllegalStateException("Remote server did not open an exec channel") + } + retryDelayMs = 2_000L + streamHerdrSnapshots(params, service) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + failed = true + if (isHerdrStreamCurrent(params)) { + _herdrState.value = + HerdrControlState.Error( + error.message ?: "Herdr snapshot stream failed", + ) + } + } finally { + if (herdrStreamService === service) herdrStreamService = null + service?.let { runCatching { it.close() } } + } + if (failed && isHerdrStreamCurrent(params)) { + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(60_000L) + } + } + } + } + + private fun stopHerdrStream() { + herdrStreamJob?.cancel() + herdrStreamJob = null + herdrStreamService?.let { service -> runCatching { service.close() } } + herdrStreamService = null + herdrIdleStretch = 0 + _herdrState.value = HerdrControlState.Inactive + } + + private suspend fun streamHerdrSnapshots( + params: ConnectionParams, + service: NativeSshService, + ) { + val buffer = StringBuilder() + var previousSnapshot: HerdrSnapshot? = null + while (currentCoroutineContext().isActive && isHerdrStreamCurrent(params)) { + val snapshot = readHerdrSnapshot(service, buffer) + herdrIdleStretch = + if (snapshot == previousSnapshot) herdrIdleStretch + 1 else 1 + previousSnapshot = snapshot + val cadence = herdrCadenceMs.value + val effectiveWait = + if (herdrIdleStretch >= 5 && cadence < 15_000L) { + minOf(cadence * 5, 15_000L) + } else { + cadence + } + if (withTimeoutOrNull(effectiveWait) { herdrPoke.receive() } != null) { + herdrIdleStretch = 0 + } + } + } + + private suspend fun readHerdrSnapshot( + service: NativeSshService, + buffer: StringBuilder, + ): HerdrSnapshot { + service.write("\n".toByteArray(Charsets.UTF_8)) + val deadline = System.currentTimeMillis() + 10_000L + while (System.currentTimeMillis() < deadline) { + val chunk = service.read(8192) + if (chunk != null && chunk.isNotEmpty()) { + var snapshot: HerdrSnapshot? = null + appendHerdrStreamChunk(buffer, String(chunk, Charsets.UTF_8)).forEach { frame -> + parseHerdrSnapshot(frame)?.let { parsed -> + _herdrState.value = + HerdrControlState.Active( + snapshot = parsed, + protocolWarning = parsed.protocol != 16, + ) + snapshot = parsed + } + } + if (snapshot != null) return snapshot + } else if (service.isChannelEof()) { + throw IllegalStateException("Herdr snapshot stream reached EOF") + } else { + delay(25) + } + } + throw IllegalStateException("Herdr snapshot read timed out") + } + private fun parseCommandEnvelope(output: String): MultiplexerCommandResult { val marker = Regex("(?:^|\\n)CHUCHU_EXIT:(\\d+)\\s*$").find(output) ?: return MultiplexerCommandResult( @@ -1024,6 +1201,7 @@ class TerminalSessionEngine( _state.value = _state.value.copy(status = SessionStatus.Disconnected) return } + stopHerdrStream() if (reconnectJob?.isActive == true) return reconnectJob = scope.launch(dispatcher) { @@ -1059,6 +1237,7 @@ class TerminalSessionEngine( requestSnapshot(force = true) startReadLoop() sendStartupCommand(params) + if (isHerdrSshSession(params)) startHerdrStream(params) return@launch } catch (e: Exception) { Log.e("TerminalSession", "Reconnect attempt $attempt failed", e) diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrStreamFramingTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrStreamFramingTest.kt new file mode 100644 index 00000000..d59e89df --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrStreamFramingTest.kt @@ -0,0 +1,99 @@ +package com.jossephus.chuchu.service.multiplexer + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HerdrStreamFramingTest { + @Test + fun extractsSingleCompleteFrame() { + val buffer = StringBuilder() + + assertEquals( + listOf("snapshot"), + appendHerdrStreamChunk(buffer, "CHUCHU_SNAP_BEGIN\nsnapshot\nCHUCHU_SNAP_END\n"), + ) + assertEquals("", buffer.toString()) + } + + @Test + fun extractsFrameWhenMarkersAreSplitAcrossChunks() { + val buffer = StringBuilder() + + assertEquals(emptyList(), appendHerdrStreamChunk(buffer, "noiseCHUCHU_SNA")) + assertEquals( + emptyList(), + appendHerdrStreamChunk(buffer, "P_BEGIN\nsnapshot\nCHUCHU_SNAP_E"), + ) + assertEquals( + listOf("snapshot"), + appendHerdrStreamChunk(buffer, "ND\n"), + ) + } + + @Test + fun extractsTwoFramesFromOneChunk() { + val buffer = StringBuilder() + + assertEquals( + listOf("first", "second"), + appendHerdrStreamChunk( + buffer, + "CHUCHU_SNAP_BEGIN\nfirst\nCHUCHU_SNAP_END\n" + + "CHUCHU_SNAP_BEGIN\nsecond\nCHUCHU_SNAP_END\n", + ), + ) + } + + @Test + fun discardsGarbageBetweenFrames() { + val buffer = StringBuilder() + + assertEquals( + listOf("first", "second"), + appendHerdrStreamChunk( + buffer, + "noiseCHUCHU_SNAP_BEGIN\nfirst\nCHUCHU_SNAP_END\n" + + "unrelated outputCHUCHU_SNAP_BEGIN\nsecond\nCHUCHU_SNAP_END\ntrailing", + ), + ) + assertEquals("", buffer.toString()) + } + + @Test + fun retainsPartialFrameUntilTheNextChunkCompletesIt() { + val buffer = StringBuilder() + + assertEquals( + emptyList(), + appendHerdrStreamChunk(buffer, "CHUCHU_SNAP_BEGIN\npartial"), + ) + assertEquals("CHUCHU_SNAP_BEGIN\npartial", buffer.toString()) + assertEquals( + listOf("partial frame"), + appendHerdrStreamChunk(buffer, " frame\nCHUCHU_SNAP_END"), + ) + } + + @Test + fun buildsSnapshotStreamAndControlCommands() { + val prelude = "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + + assertEquals( + prelude + + "while IFS= read -r _; do printf 'CHUCHU_SNAP_BEGIN\\n'; herdr api snapshot 2>/dev/null; " + + "printf '\\nCHUCHU_SNAP_END\\n'; done", + HerdrMultiplexer.snapshotStreamCommand(), + ) + assertEquals(prelude + "herdr tab focus 'tab-1'", HerdrMultiplexer.focusTabCommand("tab-1")) + assertEquals(prelude + "herdr agent focus 'pane-1'", HerdrMultiplexer.focusPaneCommand("pane-1")) + assertEquals( + prelude + "herdr tab create --workspace 'workspace-1' --focus", + HerdrMultiplexer.createTabCommand("workspace-1"), + ) + assertEquals(prelude + "herdr tab close 'tab-1'", HerdrMultiplexer.closeTabCommand("tab-1")) + assertEquals( + prelude + "herdr tab focus 'tab'\\''; touch /tmp/nope; '\\'''", + HerdrMultiplexer.focusTabCommand("tab'; touch /tmp/nope; '"), + ) + } +} From 063f20ffcaef279a1ea04760d3dc5e44197e08e3 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 00:24:33 +0300 Subject: [PATCH 04/42] feat: add native herdr panel to tab manager and command palette Renders herdr workspaces, tabs, and panes with agent-status badges, tap-to-focus, tab create/close, and refresh, sourced from the engine snapshot stream. ViewModel is a thin pass-through of engine state and verbs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/CommandPalette.kt | 21 +- .../chuchu/ui/screens/Terminal/HerdrPanel.kt | 287 ++++++++++++++++++ .../ui/screens/Terminal/TerminalScreen.kt | 25 ++ .../ui/screens/Terminal/TerminalTabManager.kt | 22 +- .../ui/screens/Terminal/TerminalViewModel.kt | 60 ++++ 5 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt index d9a1b131..67f208b7 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.multiplexer.RemoteMultiplexerSession import com.jossephus.chuchu.service.terminal.TabSession import com.jossephus.chuchu.ui.components.ChuButton @@ -79,6 +80,14 @@ fun CommandPalette( onMultiplexerRefresh: () -> Unit = {}, onMultiplexerNew: () -> Unit = {}, onMultiplexerAttach: (String) -> Unit = {}, + herdrEnabled: Boolean = false, + herdrState: HerdrControlState = HerdrControlState.Inactive, + herdrActionError: String? = null, + onHerdrFocusTab: (String) -> Unit = {}, + onHerdrFocusPane: (String) -> Unit = {}, + onHerdrCreateTab: (String) -> Unit = {}, + onHerdrCloseTab: (String) -> Unit = {}, + onHerdrRefresh: () -> Unit = {}, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -238,7 +247,17 @@ fun CommandPalette( ChuText("+ new", style = typography.labelSmall, color = colors.accent) } } - if (multiplexerEnabled) { + if (herdrEnabled) { + HerdrPanel( + state = herdrState, + onFocusTab = onHerdrFocusTab, + onFocusPane = onHerdrFocusPane, + onCreateTab = onHerdrCreateTab, + onCloseTab = onHerdrCloseTab, + onRefresh = onHerdrRefresh, + actionError = herdrActionError, + ) + } else if (multiplexerEnabled) { MultiplexerSessionPanel( sessions = multiplexerSessions, loading = multiplexerSessionsLoading, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt new file mode 100644 index 00000000..eb7ba471 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt @@ -0,0 +1,287 @@ +package com.jossephus.chuchu.ui.screens.Terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.jossephus.chuchu.service.multiplexer.HerdrAgentStatus +import com.jossephus.chuchu.service.multiplexer.HerdrControlState +import com.jossephus.chuchu.service.multiplexer.HerdrPane +import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot +import com.jossephus.chuchu.service.multiplexer.HerdrTab +import com.jossephus.chuchu.service.multiplexer.HerdrWorkspace +import com.jossephus.chuchu.ui.components.ChuButton +import com.jossephus.chuchu.ui.components.ChuButtonVariant +import com.jossephus.chuchu.ui.components.ChuText +import com.jossephus.chuchu.ui.theme.ChuColors +import com.jossephus.chuchu.ui.theme.ChuTypography + +@Composable +fun HerdrPanel( + state: HerdrControlState, + onFocusTab: (String) -> Unit, + onFocusPane: (String) -> Unit, + onCreateTab: (String) -> Unit, + onCloseTab: (String) -> Unit, + onRefresh: () -> Unit, + actionError: String?, + modifier: Modifier = Modifier, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + + Column(modifier = modifier.fillMaxWidth()) { + when (state) { + HerdrControlState.Inactive -> { + ChuText( + "herdr controls inactive", + style = typography.labelSmall, + color = colors.textMuted, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + + HerdrControlState.Connecting -> { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChuText("herdr", style = typography.body, color = colors.textSecondary) + ChuText("connecting …", style = typography.labelSmall, color = colors.textMuted) + } + } + + is HerdrControlState.Error -> { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChuText( + state.message, + style = typography.labelSmall, + color = colors.error, + modifier = Modifier.weight(1f), + ) + HerdrTextButton("retry", colors.textMuted, onRefresh) + } + } + + is HerdrControlState.Active -> { + HerdrActivePanel( + snapshot = state.snapshot, + protocolWarning = state.protocolWarning, + onFocusTab = onFocusTab, + onFocusPane = onFocusPane, + onCreateTab = onCreateTab, + onCloseTab = onCloseTab, + onRefresh = onRefresh, + ) + } + } + + if (actionError != null) { + ChuText( + actionError, + style = typography.labelSmall, + color = colors.error, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } +} + +@Composable +private fun HerdrActivePanel( + snapshot: HerdrSnapshot, + protocolWarning: Boolean, + onFocusTab: (String) -> Unit, + onFocusPane: (String) -> Unit, + onCreateTab: (String) -> Unit, + onCloseTab: (String) -> Unit, + onRefresh: () -> Unit, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ChuText("herdr", style = typography.body, color = colors.textSecondary) + Spacer(modifier = Modifier.weight(1f)) + ChuText("${snapshot.workspaces.size}", style = typography.labelSmall, color = colors.textMuted) + Spacer(modifier = Modifier.size(8.dp)) + HerdrTextButton("refresh", colors.textMuted, onRefresh) + } + if (protocolWarning) { + ChuText( + "herdr protocol newer than supported", + style = typography.labelSmall, + color = colors.textMuted, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + ) + } + if (snapshot.workspaces.isEmpty()) { + ChuText( + "no herdr workspaces", + style = typography.labelSmall, + color = colors.textMuted, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + return + } + + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) { + items(snapshot.workspaces, key = { it.workspaceId }) { workspace -> + HerdrWorkspaceGroup( + workspace = workspace, + tabs = snapshot.tabs.filter { it.workspaceId == workspace.workspaceId }, + panes = snapshot.panes, + onFocusTab = onFocusTab, + onFocusPane = onFocusPane, + onCreateTab = onCreateTab, + onCloseTab = onCloseTab, + ) + } + } +} + +@Composable +private fun HerdrWorkspaceGroup( + workspace: HerdrWorkspace, + tabs: List, + panes: List, + onFocusTab: (String) -> Unit, + onFocusPane: (String) -> Unit, + onCreateTab: (String) -> Unit, + onCloseTab: (String) -> Unit, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + val label = workspace.label?.takeIf { it.isNotBlank() } ?: "workspace ${workspace.number}" + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 8.dp, top = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChuText(label, style = typography.label, color = colors.textPrimary, modifier = Modifier.weight(1f)) + HerdrStatusBadge(workspace.agentStatus) + HerdrTextButton("+", colors.accent) { onCreateTab(workspace.workspaceId) } + } + tabs.forEach { tab -> + HerdrTabRow( + tab = tab, + panes = panes.filter { it.tabId == tab.tabId }, + onFocusTab = onFocusTab, + onFocusPane = onFocusPane, + onCloseTab = onCloseTab, + ) + } +} + +@Composable +private fun HerdrTabRow( + tab: HerdrTab, + panes: List, + onFocusTab: (String) -> Unit, + onFocusPane: (String) -> Unit, + onCloseTab: (String) -> Unit, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + val label = tab.label?.takeIf { it.isNotBlank() } ?: "tab ${tab.number}" + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .background(if (tab.focused) colors.surfaceVariant else Color.Transparent) + .border(1.dp, if (tab.focused) colors.accent.copy(alpha = 0.65f) else colors.border.copy(alpha = 0.35f)) + .clickable { onFocusTab(tab.tabId) } + .padding(start = 20.dp, end = 6.dp, top = 5.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChuText(label, style = typography.body, color = colors.textPrimary, modifier = Modifier.weight(1f)) + ChuText("${tab.paneCount}", style = typography.labelSmall, color = colors.textMuted) + HerdrStatusBadge(tab.agentStatus) + HerdrTextButton("×", colors.textMuted) { onCloseTab(tab.tabId) } + } + panes.forEach { pane -> + HerdrPaneRow(pane, onFocusPane) + } +} + +@Composable +private fun HerdrPaneRow(pane: HerdrPane, onFocusPane: (String) -> Unit) { + val colors = ChuColors.current + val typography = ChuTypography.current + val agent = pane.agent?.takeIf { it.isNotBlank() } ?: "shell" + val title = pane.terminalTitleStripped?.takeIf { it.isNotBlank() } + val label = if (title == null) agent else "$agent · $title" + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .background(if (pane.focused) colors.surfaceVariant else Color.Transparent) + .border(1.dp, if (pane.focused) colors.accent.copy(alpha = 0.5f) else colors.border.copy(alpha = 0.2f)) + .clickable { onFocusPane(pane.paneId) } + .padding(start = 36.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChuText(label, style = typography.labelSmall, color = colors.textSecondary, modifier = Modifier.weight(1f)) + HerdrStatusBadge(pane.agentStatus) + } +} + +@Composable +private fun HerdrStatusBadge(status: HerdrAgentStatus) { + val colors = ChuColors.current + val typography = ChuTypography.current + val color = when (status) { + HerdrAgentStatus.Working -> colors.accent + HerdrAgentStatus.Blocked -> colors.error + HerdrAgentStatus.Done -> colors.success + HerdrAgentStatus.Idle -> colors.textMuted + HerdrAgentStatus.Unknown -> colors.disabledText + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box(modifier = Modifier.size(6.dp).background(color)) + ChuText(status.name.lowercase(), style = typography.labelSmall, color = color) + } +} + +@Composable +private fun HerdrTextButton(label: String, color: Color, onClick: () -> Unit) { + val typography = ChuTypography.current + ChuButton( + onClick = onClick, + variant = ChuButtonVariant.Ghost, + bracketed = true, + borderColor = color, + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp), + ) { + ChuText(label, style = typography.labelSmall, color = color) + } +} diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index ad5ceff8..8d0a89ca 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -407,6 +407,7 @@ fun TerminalScreen( ) } val multiplexerState by vm.multiplexerState.collectAsStateWithLifecycle() + val herdrUiState by vm.herdrUiState.collectAsStateWithLifecycle() LaunchedEffect(terminalFontSizeSp) { settingsRepo.setTerminalFontSize(terminalFontSizeSp) @@ -567,12 +568,20 @@ fun TerminalScreen( } } + LaunchedEffect(showTabSheet, herdrUiState.enabled) { + if (showTabSheet && herdrUiState.enabled) vm.onHerdrPanelOpened() + } + LaunchedEffect(showGlobalTabManager, activeTab?.id) { if (showGlobalTabManager && activeTab?.spec?.usesRuntimeMultiplexer == true) { vm.listMultiplexerSessionsForCurrentHost() } } + LaunchedEffect(showGlobalTabManager, herdrUiState.enabled) { + if (showGlobalTabManager && herdrUiState.enabled) vm.onHerdrPanelOpened() + } + if (showPassphrasePrompt) { ChuDialog( title = "Key passphrase", @@ -1751,6 +1760,14 @@ fun TerminalScreen( onMultiplexerAttach = { name -> vm.switchToMultiplexerSession(name, multiplexerState.sessionsSourceTabId) }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + herdrActionError = herdrUiState.actionError, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrFocusPane = vm::onHerdrFocusPane, + onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrCloseTab = vm::onHerdrCloseTab, + onHerdrRefresh = vm::onHerdrPanelOpened, ) } @@ -1829,6 +1846,14 @@ fun TerminalScreen( onMultiplexerAttach = { name -> vm.switchToMultiplexerSession(name, multiplexerState.sessionsSourceTabId) }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + herdrActionError = herdrUiState.actionError, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrFocusPane = vm::onHerdrFocusPane, + onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrCloseTab = vm::onHerdrCloseTab, + onHerdrRefresh = vm::onHerdrPanelOpened, ) } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabManager.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabManager.kt index c0ed107d..3c17b34f 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabManager.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabManager.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.multiplexer.RemoteMultiplexerSession import com.jossephus.chuchu.service.terminal.SessionStatus import com.jossephus.chuchu.service.terminal.SessionState @@ -101,6 +102,14 @@ fun TerminalTabManager( onMultiplexerRefresh: () -> Unit = {}, onMultiplexerNew: () -> Unit = {}, onMultiplexerAttach: (String) -> Unit = {}, + herdrEnabled: Boolean = false, + herdrState: HerdrControlState = HerdrControlState.Inactive, + herdrActionError: String? = null, + onHerdrFocusTab: (String) -> Unit = {}, + onHerdrFocusPane: (String) -> Unit = {}, + onHerdrCreateTab: (String) -> Unit = {}, + onHerdrCloseTab: (String) -> Unit = {}, + onHerdrRefresh: () -> Unit = {}, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -426,7 +435,18 @@ fun TerminalTabManager( } } - // Multiplexer sessions section (current-host only) + if (herdrEnabled) { + HerdrPanel( + state = herdrState, + onFocusTab = onHerdrFocusTab, + onFocusPane = onHerdrFocusPane, + onCreateTab = onHerdrCreateTab, + onCloseTab = onHerdrCloseTab, + onRefresh = onHerdrRefresh, + actionError = herdrActionError, + ) + } + if (multiplexerEnabled) { MultiplexerSessionPanel( sessions = multiplexerSessions, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index a9da51e8..8ec84865 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -10,8 +10,11 @@ import com.jossephus.chuchu.data.repository.HostRepository import com.jossephus.chuchu.data.repository.SettingsRepository import com.jossephus.chuchu.data.repository.SshKeyRepository import com.jossephus.chuchu.model.HostProfile +import com.jossephus.chuchu.model.MultiplexerType import com.jossephus.chuchu.model.Transport +import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry +import com.jossephus.chuchu.service.multiplexer.MultiplexerCommandResult import com.jossephus.chuchu.service.multiplexer.RemoteMultiplexerSession import com.jossephus.chuchu.service.ssh.TailscaleStatusChecker import com.jossephus.chuchu.service.terminal.HostKeyPrompt @@ -20,6 +23,7 @@ import com.jossephus.chuchu.service.terminal.TabSession import com.jossephus.chuchu.service.terminal.TabSpec import com.jossephus.chuchu.service.terminal.TerminalMouseAction import com.jossephus.chuchu.service.terminal.TerminalMouseButton +import com.jossephus.chuchu.service.terminal.TerminalSessionEngine import com.jossephus.chuchu.service.terminal.TerminalSessionRepository import com.jossephus.chuchu.ui.screens.Files.ConnectionTab import com.jossephus.chuchu.ui.screens.Files.FileBrowserEntry @@ -42,6 +46,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -68,6 +74,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private var pendingMultiplexerAction: PendingMultiplexerAction? = null private var multiplexerActionGeneration = 0L private var multiplexerSessionListGeneration = 0L + private val _herdrActionError = MutableStateFlow(null) private val _tailscaleActive = MutableStateFlow(tailscaleStatusChecker.isActive()) val tailscaleActive: StateFlow = _tailscaleActive.asStateFlow() @@ -77,6 +84,20 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application val activeTab: StateFlow = sessionRepository.activeTab val sessionState: StateFlow = sessionRepository.sessionState val hostKeyPrompt: StateFlow = sessionRepository.hostKeyPrompt + val herdrUiState: StateFlow = + activeTab + .flatMapLatest { tab -> + if (tab != null && tab.spec.multiplexer == MultiplexerType.Herdr && + tab.spec.transport in setOf(Transport.SSH, Transport.TailscaleSSH) + ) { + combine(tab.engine.herdrState, _herdrActionError) { control, actionError -> + HerdrUiState(control = control, enabled = true, actionError = actionError) + } + } else { + flowOf(HerdrUiState()) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, HerdrUiState()) val hosts: StateFlow> = hostRepository.observeAll().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) val hostsLoaded: StateFlow = @@ -412,6 +433,39 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application sessionRepository.selectTab(id) } + fun onHerdrFocusTab(tabId: String) { + runHerdrCommand { it.herdrFocusTab(tabId) } + } + + fun onHerdrFocusPane(paneId: String) { + runHerdrCommand { it.herdrFocusPane(paneId) } + } + + fun onHerdrCreateTab(workspaceId: String) { + runHerdrCommand { it.herdrCreateTab(workspaceId) } + } + + fun onHerdrCloseTab(tabId: String) { + runHerdrCommand { it.herdrCloseTab(tabId) } + } + + fun onHerdrPanelOpened() { + _herdrActionError.value = null + sessionRepository.activeTab.value?.engine?.pokeHerdr() + } + + private fun runHerdrCommand( + command: suspend (TerminalSessionEngine) -> MultiplexerCommandResult, + ) { + val engine = sessionRepository.activeTab.value?.engine ?: return + viewModelScope.launch { + val result = command(engine) + _herdrActionError.value = + if (result.exitCode == 0) null + else result.stderr.ifBlank { result.stdout.ifBlank { "Herdr command failed" } } + } + } + fun closeTab(id: String) { _connectionTabByTab.value = _connectionTabByTab.value - id _fileBrowserStateByTab.value = _fileBrowserStateByTab.value - id @@ -756,3 +810,9 @@ data class MultiplexerUiState( val sessionsSourceHostId: Long? = null, val reconnectRecovery: Boolean = false, ) + +data class HerdrUiState( + val control: HerdrControlState = HerdrControlState.Inactive, + val enabled: Boolean = false, + val actionError: String? = null, +) From 858759ed169289f1fcf41bc693e25d377b39adb1 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 00:31:21 +0300 Subject: [PATCH 05/42] feat: notify on herdr agent blocked/done and tune polling cadence Posts throttled notifications when a herdr agent transitions to blocked or done while the app is backgrounded, with tap-to-focus of the source tab. Polling cadence now follows app visibility: 3s active tab, 15s inactive, 25s background, 60s when notifications are off. Toggle lives in terminal settings, default on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../java/com/jossephus/chuchu/MainActivity.kt | 22 +++ .../data/repository/SettingsRepository.kt | 10 ++ .../service/terminal/HerdrAgentNotifier.kt | 141 ++++++++++++++++++ .../terminal/TerminalSessionRepository.kt | 66 ++++++++ .../chuchu/ui/ApplicationNavController.kt | 3 + .../ui/screens/Settings/SettingsScreen.kt | 4 + .../Settings/TerminalAccessorySettings.kt | 29 ++++ .../terminal/HerdrAgentNotifierTest.kt | 84 +++++++++++ 8 files changed, 359 insertions(+) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrAgentNotifier.kt create mode 100644 android/app/src/test/java/com/jossephus/chuchu/service/terminal/HerdrAgentNotifierTest.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/MainActivity.kt b/android/app/src/main/java/com/jossephus/chuchu/MainActivity.kt index ac83fb5b..24f6ecfe 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/MainActivity.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/MainActivity.kt @@ -1,5 +1,7 @@ package com.jossephus.chuchu +import android.app.Application +import android.content.Intent import android.os.Bundle import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent @@ -13,12 +15,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope import com.jossephus.chuchu.data.repository.SettingsRepository +import com.jossephus.chuchu.service.terminal.HerdrAgentNotifier +import com.jossephus.chuchu.service.terminal.TerminalSessionRepository import com.jossephus.chuchu.ui.ApplicationNavController import com.jossephus.chuchu.ui.theme.ChuColors import com.jossephus.chuchu.ui.theme.ChuTheme import com.jossephus.chuchu.ui.theme.GhosttyThemeRegistry import com.jossephus.chuchu.ui.theme.resolveActiveThemeName +import kotlinx.coroutines.launch class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -30,6 +36,22 @@ class MainActivity : FragmentActivity() { setContent { AppRoot() } + handleHerdrNotificationIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleHerdrNotificationIntent(intent) + } + + private fun handleHerdrNotificationIntent(intent: Intent?) { + val sessionId = intent?.getStringExtra(HerdrAgentNotifier.EXTRA_TAB_SESSION_ID) ?: return + val herdrTabId = intent.getStringExtra(HerdrAgentNotifier.EXTRA_HERDR_TAB_ID) ?: return + val repository = TerminalSessionRepository.getInstance(application as Application) + repository.selectTab(sessionId) + val tab = repository.tabs.value.firstOrNull { it.id == sessionId } ?: return + lifecycleScope.launch { tab.engine.herdrFocusTab(herdrTabId) } } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/data/repository/SettingsRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/data/repository/SettingsRepository.kt index 17d7c599..422e90a3 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/data/repository/SettingsRepository.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/data/repository/SettingsRepository.kt @@ -53,6 +53,10 @@ class SettingsRepository(context: Context) { private val _localShellEnabled = MutableStateFlow(prefs.getBoolean(KEY_LOCAL_SHELL_ENABLED, true)) val localShellEnabled: StateFlow = _localShellEnabled.asStateFlow() + private val _herdrNotificationsEnabled = + MutableStateFlow(prefs.getBoolean(KEY_HERDR_NOTIFICATIONS, true)) + val herdrNotificationsEnabled: StateFlow = _herdrNotificationsEnabled.asStateFlow() + private val _terminalTabMode = MutableStateFlow( parseTabMode(prefs.getString(KEY_TAB_MODE, TerminalTabMode.Classic.name)), ) @@ -131,6 +135,11 @@ class SettingsRepository(context: Context) { _localShellEnabled.value = enabled } + fun setHerdrNotificationsEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_HERDR_NOTIFICATIONS, enabled).apply() + _herdrNotificationsEnabled.value = enabled + } + fun setThemeMode(mode: ThemeMode) { prefs.edit().putString(KEY_THEME_MODE, mode.name).apply() _themeMode.value = mode @@ -193,6 +202,7 @@ class SettingsRepository(context: Context) { private const val KEY_APP_LOCK_ENABLED = "app_lock_enabled" private const val KEY_REQUIRE_AUTH_ON_CONNECT = "require_auth_on_connect" private const val KEY_LOCAL_SHELL_ENABLED = "local_shell_enabled" + private const val KEY_HERDR_NOTIFICATIONS = "herdr_notifications_enabled" private const val KEY_THEME_MODE = "theme_mode" private const val KEY_LIGHT_THEME = "light_theme_name" private const val KEY_TERMINAL_FONT_SIZE = "terminal_font_size_sp" diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrAgentNotifier.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrAgentNotifier.kt new file mode 100644 index 00000000..f91b6de6 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrAgentNotifier.kt @@ -0,0 +1,141 @@ +package com.jossephus.chuchu.service.terminal + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.jossephus.chuchu.MainActivity +import com.jossephus.chuchu.R +import com.jossephus.chuchu.service.multiplexer.HerdrAgentStatus +import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot + +internal class HerdrAgentNotifier( + private val context: Context, +) { + private data class PaneKey(val tabSessionId: String, val paneId: String) + + private val lastStatusByPane = mutableMapOf() + private val lastPostedAtByPane = mutableMapOf() + + fun onSnapshot( + tabSessionId: String, + tabLabel: String, + snapshot: HerdrSnapshot, + foreground: Boolean, + enabled: Boolean, + now: Long = System.currentTimeMillis(), + ) { + val visibleKeys = snapshot.panes.mapTo(mutableSetOf()) { pane -> + PaneKey(tabSessionId, pane.paneId) + } + prunePanes(tabSessionId, visibleKeys) + + snapshot.panes.forEach { pane -> + val key = PaneKey(tabSessionId, pane.paneId) + val previous = lastStatusByPane[key] + val lastPostedAt = lastPostedAtByPane[key] + if ( + shouldNotify( + previousStatus = previous, + newStatus = pane.agentStatus, + foreground = foreground, + enabled = enabled, + lastPostedAt = lastPostedAt, + now = now, + ) && NotificationManagerCompat.from(context).areNotificationsEnabled() + ) { + postNotification( + tabSessionId, + pane.paneId, + pane.tabId, + pane.agent, + pane.agentStatus, + pane.terminalTitleStripped, + tabLabel, + ) + lastPostedAtByPane[key] = now + } + lastStatusByPane[key] = pane.agentStatus + } + } + + fun removeTab(tabSessionId: String) { + lastStatusByPane.keys.removeAll { it.tabSessionId == tabSessionId } + lastPostedAtByPane.keys.removeAll { it.tabSessionId == tabSessionId } + } + + private fun prunePanes(tabSessionId: String, visibleKeys: Set) { + lastStatusByPane.keys.removeAll { it.tabSessionId == tabSessionId && it !in visibleKeys } + lastPostedAtByPane.keys.removeAll { it.tabSessionId == tabSessionId && it !in visibleKeys } + } + + private fun postNotification( + tabSessionId: String, + paneId: String, + herdrTabId: String, + agent: String?, + status: HerdrAgentStatus, + terminalTitle: String?, + tabLabel: String, + ) { + ensureNotificationChannel() + val notificationId = "$tabSessionId:$paneId".hashCode() + val tapIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_TAB_SESSION_ID, tabSessionId) + putExtra(EXTRA_HERDR_TAB_ID, herdrTabId) + } + val tapPending = PendingIntent.getActivity( + context, + notificationId, + tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val subject = agent?.takeIf { it.isNotBlank() } ?: "pane" + val action = if (status == HerdrAgentStatus.Blocked) "blocked" else "finished" + val body = terminalTitle?.takeIf { it.isNotBlank() } ?: tabLabel + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("$subject $action") + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(tapPending) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .build() + NotificationManagerCompat.from(context).notify(notificationId, notification) + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Herdr agents", NotificationManager.IMPORTANCE_DEFAULT), + ) + } + + companion object { + const val EXTRA_TAB_SESSION_ID = "herdr_tab_session_id" + const val EXTRA_HERDR_TAB_ID = "herdr_tab_id" + private const val CHANNEL_ID = "chuchu_herdr_agents" + private const val THROTTLE_MS = 30_000L + + internal fun shouldNotify( + previousStatus: HerdrAgentStatus?, + newStatus: HerdrAgentStatus, + foreground: Boolean, + enabled: Boolean, + lastPostedAt: Long?, + now: Long, + ): Boolean = + previousStatus != newStatus && + (newStatus == HerdrAgentStatus.Blocked || newStatus == HerdrAgentStatus.Done) && + !foreground && + enabled && + (lastPostedAt == null || now - lastPostedAt >= THROTTLE_MS) + } +} diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt index 5712895e..37aed75f 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt @@ -3,8 +3,10 @@ package com.jossephus.chuchu.service.terminal import android.app.Application import android.content.ClipData import android.content.ClipboardManager +import com.jossephus.chuchu.data.repository.SettingsRepository import com.jossephus.chuchu.model.MultiplexerType import com.jossephus.chuchu.model.Transport +import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry import com.jossephus.chuchu.service.multiplexer.RemoteMultiplexerSession import com.jossephus.chuchu.service.ssh.HostKeyStore @@ -40,9 +42,13 @@ class TerminalSessionRepository private constructor(application: Application) { private val hostKeyStore = HostKeyStore(appContext.getSharedPreferences("host_keys", Application.MODE_PRIVATE)) + private val settingsRepository = SettingsRepository.getInstance(appContext) private val tailscaleStatusChecker = TailscaleStatusChecker(appContext) private val clipboard = appContext.getSystemService(ClipboardManager::class.java) private val osc52ClipboardPolicy = Osc52ClipboardPolicy.Deny + private val herdrAgentNotifier = HerdrAgentNotifier(appContext) + private val herdrStateJobs = mutableMapOf() + private val herdrCadenceByTab = mutableMapOf() private fun publishTerminalClipboard(tabId: String, text: String) { if (!canPublishTerminalClipboard(tabId)) return @@ -112,6 +118,9 @@ class TerminalSessionRepository private constructor(application: Application) { private var foregroundNotificationLabel: String? = null init { + scope.launch { + settingsRepository.herdrNotificationsEnabled.collect { updateHerdrCadence() } + } scope.launch { combine(_tabs, _activeTabId) { tabs, _ -> tabs } .flatMapLatest { tabs -> @@ -124,6 +133,7 @@ class TerminalSessionRepository private constructor(application: Application) { } } .collect { pairs -> + updateHerdrCadence() val anyAlive = pairs.any { (_, state) -> state.status == SessionStatus.Connecting || @@ -146,10 +156,12 @@ class TerminalSessionRepository private constructor(application: Application) { fun attachClient() { attachedClients += 1 + updateHerdrCadence() } fun detachClient() { attachedClients = (attachedClients - 1).coerceAtLeast(0) + updateHerdrCadence() } private fun currentNotificationLabel(): String { @@ -228,6 +240,7 @@ class TerminalSessionRepository private constructor(application: Application) { val tab = TabSession(id, spec, engine) _tabs.value = _tabs.value + tab _activeTabId.value = id + observeHerdrState(tab) engine.connect( host = spec.host, port = spec.port, @@ -244,23 +257,29 @@ class TerminalSessionRepository private constructor(application: Application) { multiplexerSessionName = spec.multiplexerSessionName, multiplexerCreateIfMissing = spec.multiplexerCreateIfMissing, ) + updateHerdrCadence() return tab } fun selectTab(id: String) { if (_tabs.value.any { it.id == id }) { _activeTabId.value = id + updateHerdrCadence() } } fun closeTab(id: String) { val tab = _tabs.value.firstOrNull { it.id == id } ?: return + herdrStateJobs.remove(id)?.cancel() + herdrCadenceByTab.remove(id) + herdrAgentNotifier.removeTab(id) val remaining = _tabs.value.filterNot { it.id == id } _tabs.value = remaining if (_activeTabId.value == id) { val nextSameHost = remaining.firstOrNull { it.spec.hostId == tab.spec.hostId } _activeTabId.value = nextSameHost?.id ?: remaining.firstOrNull()?.id } + updateHerdrCadence() tab.engine.dispose() } @@ -312,9 +331,56 @@ class TerminalSessionRepository private constructor(application: Application) { val tabs = _tabs.value _tabs.value = emptyList() _activeTabId.value = null + herdrStateJobs.values.forEach(Job::cancel) + herdrStateJobs.clear() + herdrCadenceByTab.clear() + tabs.forEach { herdrAgentNotifier.removeTab(it.id) } tabs.forEach { it.engine.dispose() } } + private fun observeHerdrState(tab: TabSession) { + if (tab.spec.multiplexer != MultiplexerType.Herdr) return + herdrStateJobs[tab.id] = + scope.launch { + tab.engine.herdrState.collect { state -> + if (state is HerdrControlState.Active) { + herdrAgentNotifier.onSnapshot( + tabSessionId = tab.id, + tabLabel = tab.spec.tabLabel, + snapshot = state.snapshot, + foreground = attachedClients > 0, + enabled = settingsRepository.herdrNotificationsEnabled.value, + ) + } + } + } + } + + private fun updateHerdrCadence() { + val foreground = attachedClients > 0 + val activeTabId = _activeTabId.value + val notificationsEnabled = settingsRepository.herdrNotificationsEnabled.value + _tabs.value + .asSequence() + .filter { tab -> + tab.spec.multiplexer == MultiplexerType.Herdr && + tab.sessionState.value.status == SessionStatus.Connected + } + .forEach { tab -> + val cadence = + when { + foreground && tab.id == activeTabId -> 3_000L + foreground -> 15_000L + notificationsEnabled -> 25_000L + else -> 60_000L + } + if (herdrCadenceByTab[tab.id] != cadence) { + herdrCadenceByTab[tab.id] = cadence + tab.engine.setHerdrCadence(cadence) + } + } + } + private fun activeEngine(): TerminalSessionEngine? = activeTab.value?.engine private fun sessionKeyFor(tab: TabSession): String = diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/ApplicationNavController.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/ApplicationNavController.kt index 5ec143e0..cb50ac31 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/ApplicationNavController.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/ApplicationNavController.kt @@ -127,6 +127,7 @@ fun ApplicationNavController() { val builtinShortcuts by settingsRepo.builtinShortcuts.collectAsStateWithLifecycle() val tabMode by settingsRepo.terminalTabMode.collectAsStateWithLifecycle() val localShellEnabled by settingsRepo.localShellEnabled.collectAsStateWithLifecycle() + val herdrNotificationsEnabled by settingsRepo.herdrNotificationsEnabled.collectAsStateWithLifecycle() val themeMode by settingsRepo.themeMode.collectAsStateWithLifecycle() val terminalFontSize by settingsRepo.terminalFontSize.collectAsStateWithLifecycle() val lightThemeName by settingsRepo.lightThemeName.collectAsStateWithLifecycle() @@ -136,6 +137,7 @@ fun ApplicationNavController() { appLockEnabled = appLockEnabled, requireAuthOnConnect = requireAuthOnConnect, localShellEnabled = localShellEnabled, + herdrNotificationsEnabled = herdrNotificationsEnabled, currentAccessoryLayoutIds = accessoryLayoutIds, accessoryBarSingleRow = accessoryBarSingleRow, currentTerminalCustomKeyGroups = customKeyGroups, @@ -154,6 +156,7 @@ fun ApplicationNavController() { onAppLockEnabledChanged = settingsRepo::setAppLockEnabled, onRequireAuthOnConnectChanged = settingsRepo::setRequireAuthOnConnect, onLocalShellEnabledChanged = settingsRepo::setLocalShellEnabled, + onHerdrNotificationsEnabledChanged = settingsRepo::setHerdrNotificationsEnabled, onAccessoryLayoutChanged = settingsRepo::setAccessoryLayoutIds, onAccessoryBarSingleRowChanged = settingsRepo::setAccessoryBarSingleRow, currentTerminalFontSize = terminalFontSize, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/SettingsScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/SettingsScreen.kt index badf2604..13af36b4 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/SettingsScreen.kt @@ -46,6 +46,7 @@ fun SettingsScreen( appLockEnabled: Boolean, requireAuthOnConnect: Boolean, localShellEnabled: Boolean, + herdrNotificationsEnabled: Boolean, currentAccessoryLayoutIds: List, accessoryBarSingleRow: Boolean, currentTerminalCustomKeyGroups: List, @@ -64,6 +65,7 @@ fun SettingsScreen( onAppLockEnabledChanged: (Boolean) -> Unit, onRequireAuthOnConnectChanged: (Boolean) -> Unit, onLocalShellEnabledChanged: (Boolean) -> Unit, + onHerdrNotificationsEnabledChanged: (Boolean) -> Unit, onAccessoryLayoutChanged: (List) -> Unit, onAccessoryBarSingleRowChanged: (Boolean) -> Unit, currentTerminalFontSize: Float = 14f, @@ -187,6 +189,8 @@ fun SettingsScreen( onTabModeChanged = onTabModeChanged, localShellEnabled = localShellEnabled, onLocalShellEnabledChanged = onLocalShellEnabledChanged, + herdrNotificationsEnabled = herdrNotificationsEnabled, + onHerdrNotificationsEnabledChanged = onHerdrNotificationsEnabledChanged, ) } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/TerminalAccessorySettings.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/TerminalAccessorySettings.kt index 40c1aa53..81d8fcd4 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/TerminalAccessorySettings.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Settings/TerminalAccessorySettings.kt @@ -93,6 +93,8 @@ internal fun TerminalSettings( onTabModeChanged: (TerminalTabMode) -> Unit = {}, localShellEnabled: Boolean = false, onLocalShellEnabledChanged: (Boolean) -> Unit = {}, + herdrNotificationsEnabled: Boolean = true, + onHerdrNotificationsEnabledChanged: (Boolean) -> Unit = {}, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -486,6 +488,33 @@ internal fun TerminalSettings( ) } } + + ChuCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + ChuText("Herdr agent notifications", style = typography.label) + ChuText( + "alert when background agents need attention or finish", + style = typography.bodySmall, + color = colors.textMuted, + ) + } + ChuSwitch( + checked = herdrNotificationsEnabled, + onCheckedChange = onHerdrNotificationsEnabledChanged, + ) + } + } } } diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/terminal/HerdrAgentNotifierTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/terminal/HerdrAgentNotifierTest.kt new file mode 100644 index 00000000..4233a0b9 --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/terminal/HerdrAgentNotifierTest.kt @@ -0,0 +1,84 @@ +package com.jossephus.chuchu.service.terminal + +import com.jossephus.chuchu.service.multiplexer.HerdrAgentStatus +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HerdrAgentNotifierTest { + @Test + fun notifiesWhenPaneBecomesBlockedInBackground() { + assertTrue( + HerdrAgentNotifier.shouldNotify( + previousStatus = HerdrAgentStatus.Working, + newStatus = HerdrAgentStatus.Blocked, + foreground = false, + enabled = true, + lastPostedAt = null, + now = 1_000L, + ), + ) + } + + @Test + fun doesNotNotifyWithoutAttentionTransitionOrWhenForeground() { + assertFalse( + HerdrAgentNotifier.shouldNotify( + previousStatus = HerdrAgentStatus.Done, + newStatus = HerdrAgentStatus.Done, + foreground = false, + enabled = true, + lastPostedAt = null, + now = 1_000L, + ), + ) + assertFalse( + HerdrAgentNotifier.shouldNotify( + previousStatus = HerdrAgentStatus.Working, + newStatus = HerdrAgentStatus.Done, + foreground = true, + enabled = true, + lastPostedAt = null, + now = 1_000L, + ), + ) + } + + @Test + fun throttlesNotificationsForTheSamePane() { + assertFalse( + HerdrAgentNotifier.shouldNotify( + previousStatus = HerdrAgentStatus.Working, + newStatus = HerdrAgentStatus.Done, + foreground = false, + enabled = true, + lastPostedAt = 1_000L, + now = 30_999L, + ), + ) + assertTrue( + HerdrAgentNotifier.shouldNotify( + previousStatus = HerdrAgentStatus.Working, + newStatus = HerdrAgentStatus.Done, + foreground = false, + enabled = true, + lastPostedAt = 1_000L, + now = 31_000L, + ), + ) + } + + @Test + fun doesNotNotifyWhenDisabled() { + assertFalse( + HerdrAgentNotifier.shouldNotify( + previousStatus = HerdrAgentStatus.Working, + newStatus = HerdrAgentStatus.Blocked, + foreground = false, + enabled = false, + lastPostedAt = null, + now = 1_000L, + ), + ) + } +} From 872c559d6e54828b072060db0f5a2bc3fca683e7 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 09:13:48 +0300 Subject: [PATCH 06/42] feat: show herdr tabs in the terminal tab strip When the active session is a herdr multiplexer, the always-visible strip renders the focused workspace's herdr tabs with agent-status dots and tap-to-focus, a host chip opening the tab manager, and a plus button that creates a herdr tab. Falls back to the session strip otherwise. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../chuchu/ui/screens/Terminal/HerdrPanel.kt | 18 +- .../ui/screens/Terminal/TerminalScreen.kt | 20 ++ .../ui/screens/Terminal/TerminalTabStrip.kt | 196 ++++++++++++++---- 3 files changed, 187 insertions(+), 47 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt index eb7ba471..fe02ab61 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt @@ -30,8 +30,18 @@ import com.jossephus.chuchu.ui.components.ChuButton import com.jossephus.chuchu.ui.components.ChuButtonVariant import com.jossephus.chuchu.ui.components.ChuText import com.jossephus.chuchu.ui.theme.ChuColors +import com.jossephus.chuchu.ui.theme.ChuColorPalette import com.jossephus.chuchu.ui.theme.ChuTypography +internal fun herdrAgentStatusColor(status: HerdrAgentStatus, colors: ChuColorPalette): Color = + when (status) { + HerdrAgentStatus.Working -> colors.accent + HerdrAgentStatus.Blocked -> colors.error + HerdrAgentStatus.Done -> colors.success + HerdrAgentStatus.Idle -> colors.textMuted + HerdrAgentStatus.Unknown -> colors.disabledText + } + @Composable fun HerdrPanel( state: HerdrControlState, @@ -259,13 +269,7 @@ private fun HerdrPaneRow(pane: HerdrPane, onFocusPane: (String) -> Unit) { private fun HerdrStatusBadge(status: HerdrAgentStatus) { val colors = ChuColors.current val typography = ChuTypography.current - val color = when (status) { - HerdrAgentStatus.Working -> colors.accent - HerdrAgentStatus.Blocked -> colors.error - HerdrAgentStatus.Done -> colors.success - HerdrAgentStatus.Idle -> colors.textMuted - HerdrAgentStatus.Unknown -> colors.disabledText - } + val color = herdrAgentStatusColor(status, colors) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { Box(modifier = Modifier.size(6.dp).background(color)) ChuText(status.name.lowercase(), style = typography.labelSmall, color = color) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index 8d0a89ca..fc80b1b6 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -686,6 +686,11 @@ fun TerminalScreen( onTabSelected = { id -> vm.selectTab(id) }, onAddTab = openAnotherSessionForCurrentHost, onOpenManager = { showGlobalTabManager = true }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrCreateTab = vm::onHerdrCreateTab, + hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -770,6 +775,11 @@ fun TerminalScreen( onTabSelected = { id -> vm.selectTab(id) }, onAddTab = openAnotherSessionForCurrentHost, onOpenManager = { showGlobalTabManager = true }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrCreateTab = vm::onHerdrCreateTab, + hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -1089,6 +1099,11 @@ fun TerminalScreen( onOpenManager = { showGlobalTabManager = true }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrCreateTab = vm::onHerdrCreateTab, + hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), ) } @@ -1780,6 +1795,11 @@ fun TerminalScreen( onTabSelected = { id -> vm.selectTab(id) }, onAddTab = openAnotherSessionForCurrentHost, onOpenManager = { showGlobalTabManager = true }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrCreateTab = vm::onHerdrCreateTab, + hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt index 2124d353..9ecee9a0 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -30,6 +32,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.terminal.SessionStatus import kotlinx.coroutines.flow.map import kotlin.math.roundToInt @@ -65,6 +68,11 @@ fun TerminalTabStrip( onAddTab: () -> Unit, onOpenManager: () -> Unit, modifier: Modifier = Modifier, + herdrEnabled: Boolean = false, + herdrState: HerdrControlState = HerdrControlState.Inactive, + onHerdrFocusTab: (String) -> Unit = {}, + onHerdrCreateTab: (String) -> Unit = {}, + hostChipLabel: String? = null, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -72,9 +80,20 @@ fun TerminalTabStrip( val trailingActionWidth = if (tabs.size > 1) 72.dp else 40.dp val tabOffsets = remember { mutableStateMapOf() } val rowRootLeft = remember { mutableStateOf(0) } + val herdrSnapshot = (herdrState as? HerdrControlState.Active)?.snapshot + val focusedWorkspaceId = herdrSnapshot?.focusedWorkspaceId + val herdrTabs = + herdrSnapshot + ?.tabs + ?.filter { it.workspaceId == focusedWorkspaceId } + ?.sortedBy { it.number } + .orEmpty() + val showHerdrTabs = herdrEnabled && herdrSnapshot != null && herdrTabs.isNotEmpty() + val focusedHerdrTabId = herdrTabs.firstOrNull { it.focused }?.tabId ?: herdrSnapshot?.focusedTabId + val scrollTargetId = if (showHerdrTabs) focusedHerdrTabId else activeTabId - LaunchedEffect(activeTabId) { - val target = activeTabId?.let { tabOffsets[it] } ?: return@LaunchedEffect + LaunchedEffect(scrollTargetId) { + val target = scrollTargetId?.let { tabOffsets[it] } ?: return@LaunchedEffect scrollState.animateScrollTo(target) } @@ -84,58 +103,152 @@ fun TerminalTabStrip( .background(colors.surfaceVariant) .padding(start = 4.dp, end = 6.dp, top = 3.dp, bottom = 5.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(end = trailingActionWidth) - .horizontalScroll(scrollState) - .onGloballyPositioned { coords -> - rowRootLeft.value = coords.localToRoot(Offset.Zero).x.roundToInt() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - tabs.forEach { tab -> - val isActive = tab.id == activeTabId - val alias = terminalTabDisplayLabel(tab) - val title by remember(tab) { - tab.sessionState.map { it.title?.takeIf(String::isNotBlank) } - }.collectAsStateWithLifecycle(initialValue = null) - val label = title ?: alias - + if (showHerdrTabs) { + Row( + modifier = Modifier.fillMaxWidth().padding(end = trailingActionWidth), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + val hostLabel = hostChipLabel + ?: tabs.firstOrNull { it.id == activeTabId }?.let(::terminalTabDisplayLabel) + ?: "terminal" Box( modifier = Modifier - .onGloballyPositioned { coords -> - tabOffsets[tab.id] = coords.localToRoot(Offset.Zero).x.roundToInt() - rowRootLeft.value + scrollState.value - } - .semantics { contentDescription = label } + .semantics { contentDescription = hostLabel } .defaultMinSize(minHeight = 32.dp) .widthIn(min = 44.dp, max = 160.dp) .clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp)) - .background( - if (isActive) colors.accent.copy(alpha = 0.16f) - else Color.Transparent - ) - .clickable { onTabSelected(tab.id) } + .clickable(onClick = onOpenManager) .padding(horizontal = 8.dp, vertical = 3.dp), contentAlignment = Alignment.Center, ) { ChuText( - text = label, + text = hostLabel, style = typography.labelSmall, - color = if (isActive) colors.accent else colors.textSecondary, + color = colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (isActive) { + } + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(scrollState) + .onGloballyPositioned { coords -> + rowRootLeft.value = coords.localToRoot(Offset.Zero).x.roundToInt() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + herdrTabs.forEach { tab -> + val isFocused = tab.focused + val label = tab.label?.takeIf { it.isNotBlank() } ?: "tab ${tab.number}" + val statusColor = herdrAgentStatusColor(tab.agentStatus, colors) + Box( modifier = Modifier - .align(Alignment.BottomCenter) - .padding(start = 2.dp, end = 2.dp, bottom = 1.dp) - .fillMaxWidth() - .height(1.dp) - .background(colors.accent), + .onGloballyPositioned { coords -> + tabOffsets[tab.tabId] = coords.localToRoot(Offset.Zero).x.roundToInt() - rowRootLeft.value + scrollState.value + } + .semantics { contentDescription = label } + .defaultMinSize(minHeight = 32.dp) + .widthIn(min = 44.dp, max = 160.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp)) + .background( + if (isFocused) colors.accent.copy(alpha = 0.16f) + else Color.Transparent + ) + .clickable { onHerdrFocusTab(tab.tabId) } + .padding(horizontal = 8.dp, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier.size(6.dp).background( + statusColor, + CircleShape, + ), + ) + ChuText( + text = label, + style = typography.labelSmall, + color = if (isFocused) colors.accent else colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (isFocused) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(start = 2.dp, end = 2.dp, bottom = 1.dp) + .fillMaxWidth() + .height(1.dp) + .background(colors.accent), + ) + } + } + } + } + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = trailingActionWidth) + .horizontalScroll(scrollState) + .onGloballyPositioned { coords -> + rowRootLeft.value = coords.localToRoot(Offset.Zero).x.roundToInt() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + tabs.forEach { tab -> + val isActive = tab.id == activeTabId + val alias = terminalTabDisplayLabel(tab) + val title by remember(tab) { + tab.sessionState.map { it.title?.takeIf(String::isNotBlank) } + }.collectAsStateWithLifecycle(initialValue = null) + val label = title ?: alias + + Box( + modifier = Modifier + .onGloballyPositioned { coords -> + tabOffsets[tab.id] = coords.localToRoot(Offset.Zero).x.roundToInt() - rowRootLeft.value + scrollState.value + } + .semantics { contentDescription = label } + .defaultMinSize(minHeight = 32.dp) + .widthIn(min = 44.dp, max = 160.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp)) + .background( + if (isActive) colors.accent.copy(alpha = 0.16f) + else Color.Transparent + ) + .clickable { onTabSelected(tab.id) } + .padding(horizontal = 8.dp, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + ChuText( + text = label, + style = typography.labelSmall, + color = if (isActive) colors.accent else colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + if (isActive) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(start = 2.dp, end = 2.dp, bottom = 1.dp) + .fillMaxWidth() + .height(1.dp) + .background(colors.accent), + ) + } } } } @@ -163,11 +276,14 @@ fun TerminalTabStrip( } ChuButton( - onClick = onAddTab, + onClick = { + if (showHerdrTabs) focusedWorkspaceId?.let(onHerdrCreateTab) + else onAddTab() + }, modifier = Modifier.defaultMinSize(minHeight = 32.dp, minWidth = 32.dp), variant = ChuButtonVariant.Ghost, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), - contentDescription = "new connection", + contentDescription = if (showHerdrTabs) "new herdr tab" else "new connection", ) { ChuText("+", style = typography.label, color = colors.accent) } From 248aa8dfd34be259f53217816ce8d445932e53c2 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 09:58:36 +0300 Subject: [PATCH 07/42] feat: parse herdr layout geometry in snapshot Adds layouts[] models (tab area, per-pane rects in terminal cells, focused pane, zoom flag) needed to render herdr tabs as native splits. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrSnapshot.kt | 26 +++++++++++++ .../multiplexer/HerdrSnapshotParserTest.kt | 38 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt index 54931115..469107cf 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt @@ -20,11 +20,37 @@ data class HerdrSnapshot( val workspaces: List = emptyList(), val tabs: List = emptyList(), val panes: List = emptyList(), + val layouts: List = emptyList(), @SerialName("focused_workspace_id") val focusedWorkspaceId: String? = null, @SerialName("focused_tab_id") val focusedTabId: String? = null, @SerialName("focused_pane_id") val focusedPaneId: String? = null, ) +@Serializable +data class HerdrRect( + val x: Int = 0, + val y: Int = 0, + val width: Int = 0, + val height: Int = 0, +) + +@Serializable +data class HerdrLayoutPane( + @SerialName("pane_id") val paneId: String = "", + val rect: HerdrRect = HerdrRect(), + val focused: Boolean = false, +) + +@Serializable +data class HerdrTabLayout( + @SerialName("tab_id") val tabId: String = "", + @SerialName("workspace_id") val workspaceId: String = "", + val area: HerdrRect = HerdrRect(), + @SerialName("focused_pane_id") val focusedPaneId: String? = null, + val panes: List = emptyList(), + val zoomed: Boolean = false, +) + sealed interface HerdrControlState { data object Inactive : HerdrControlState diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt index cd00fa4a..2728e116 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt @@ -16,6 +16,7 @@ class HerdrSnapshotParserTest { assertEquals(1, snapshot.workspaces.size) assertEquals(2, snapshot.tabs.size) assertEquals(2, snapshot.panes.size) + assertEquals(emptyList(), snapshot.layouts) val pane = snapshot.panes.first { it.paneId == "wJ:p5Y" } assertEquals("pi", pane.agent) @@ -54,6 +55,40 @@ class HerdrSnapshotParserTest { ), snapshot.panes.map { it.agentStatus }, ) + assertEquals(emptyList(), snapshot.layouts) + } + + @Test + fun parsesRealLayoutFixture() { + val snapshot = parseHerdrSnapshot(realLayoutSnapshotFixture)!! + + assertEquals(1, snapshot.layouts.size) + val layout = snapshot.layouts.single() + assertEquals("wJ:t1M", layout.tabId) + assertEquals(HerdrRect(x = 24, y = 1, width = 166, height = 56), layout.area) + assertEquals("wJ:p6Q", layout.focusedPaneId) + assertEquals(false, layout.zoomed) + assertEquals(3, layout.panes.size) + assertEquals( + listOf( + HerdrLayoutPane( + paneId = "wJ:p6Q", + rect = HerdrRect(x = 24, y = 1, width = 83, height = 28), + focused = true, + ), + HerdrLayoutPane( + paneId = "wJ:p6S", + rect = HerdrRect(x = 24, y = 29, width = 83, height = 28), + focused = false, + ), + HerdrLayoutPane( + paneId = "wJ:p6R", + rect = HerdrRect(x = 107, y = 1, width = 83, height = 56), + focused = false, + ), + ), + layout.panes, + ) } @Test @@ -78,6 +113,9 @@ class HerdrSnapshotParserTest { } private companion object { + val realLayoutSnapshotFixture = + """{"id":"cli:api:snapshot","result":{"snapshot":{"agents":[],"focused_pane_id":"wC:p3","focused_tab_id":"wC:t2","focused_workspace_id":"wC","layouts":[{"area":{"height":56,"width":166,"x":24,"y":1},"focused_pane_id":"wJ:p6Q","panes":[{"focused":true,"pane_id":"wJ:p6Q","rect":{"height":28,"width":83,"x":24,"y":1}},{"focused":false,"pane_id":"wJ:p6S","rect":{"height":28,"width":83,"x":24,"y":29}},{"focused":false,"pane_id":"wJ:p6R","rect":{"height":56,"width":83,"x":107,"y":1}}],"splits":[{"direction":"right","id":"split_0_root","ratio":0.5,"rect":{"height":56,"width":166,"x":24,"y":1}},{"direction":"down","id":"split_1_0","ratio":0.5,"rect":{"height":56,"width":83,"x":24,"y":1}}],"tab_id":"wJ:t1M","workspace_id":"wJ","zoomed":false}],"panes":[{"agent_status":"unknown","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p6Q","revision":0,"scroll":{"max_offset_from_bottom":0,"offset_from_bottom":0,"viewport_rows":26},"tab_id":"wJ:t1M","terminal_id":"term_657055a4e306981","workspace_id":"wJ"},{"agent_status":"unknown","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p6S","revision":0,"scroll":{"max_offset_from_bottom":0,"offset_from_bottom":0,"viewport_rows":26},"tab_id":"wJ:t1M","terminal_id":"term_657055a4fc77c83","workspace_id":"wJ"},{"agent_status":"unknown","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p6R","revision":0,"scroll":{"max_offset_from_bottom":0,"offset_from_bottom":0,"viewport_rows":54},"tab_id":"wJ:t1M","terminal_id":"term_657055a4f68b682","workspace_id":"wJ"}],"protocol":16,"tabs":[{"agent_status":"unknown","focused":false,"label":"4","number":52,"pane_count":3,"tab_id":"wJ:t1M","workspace_id":"wJ"}],"version":"0.7.4","workspaces":[{"active_tab_id":"wJ:t1H","agent_status":"working","focused":false,"label":"ERBareeq","number":1,"pane_count":7,"tab_count":4,"workspace_id":"wJ"}]},"type":"session_snapshot"}}""" + val realSnapshotFixture = """{"id":"cli:api:snapshot","result":{"snapshot":{"agents":[{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"/home/salem/.pi/agent/sessions/--home-salem-coding-ERBareeq--/2026-07-16T07-13-22-703Z_019f69c5-dd0f-7da2-a8c5-559c1d548757.jsonl"},"agent_status":"idle","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p3D","revision":10,"screen_detection_skipped":true,"tab_id":"wJ:tR","terminal_id":"term_656acc0585f9a2","terminal_title":"\u03c0 - ERBareeq: MSSQL Sync Layer Refactor - ERBareeq","terminal_title_stripped":"\u03c0 - ERBareeq: MSSQL Sync Layer Refactor - ERBareeq","workspace_id":"wJ"},{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"/home/salem/.pi/agent/sessions/--home-salem-coding-ERBareeq--/2026-07-18T21-36-52-186Z_019f7729-21da-79d6-833b-6af0d72af2cb.jsonl"},"agent_status":"idle","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p52","revision":16,"screen_detection_skipped":true,"tab_id":"wJ:t16","terminal_id":"term_656e384d6906144","terminal_title":"\u03c0 - ERBareeq: Fix Bareeq Chat UI Papercuts - ERBareeq","terminal_title_stripped":"\u03c0 - ERBareeq: Fix Bareeq Chat UI Papercuts - ERBareeq","tokens":{"pi_session_name":"ERBareeq: Fix Bareeq Chat UI Papercuts"},"workspace_id":"wJ"}],"focused_pane_id":"wJ:p5Y","focused_tab_id":"wJ:t1H","focused_workspace_id":"wJ","layouts":[],"panes":[{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"/home/salem/.pi/agent/sessions/--home-salem-coding-ERBareeq--/2026-07-16T07-13-22-703Z_019f69c5-dd0f-7da2-a8c5-559c1d548757.jsonl"},"agent_status":"idle","cwd":"/home/salem/coding/ERBareeq","focused":false,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p3D","revision":10,"scroll":{"max_offset_from_bottom":535,"offset_from_bottom":0,"viewport_rows":56},"tab_id":"wJ:tR","terminal_id":"term_656acc0585f9a2","terminal_title":"\u03c0 - ERBareeq: MSSQL Sync Layer Refactor - ERBareeq","terminal_title_stripped":"\u03c0 - ERBareeq: MSSQL Sync Layer Refactor - ERBareeq","workspace_id":"wJ"},{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"/home/salem/.pi/agent/sessions/--home-salem-coding-ERBareeq--/2026-07-19T19-19-42-452Z_019f7bd1-ea74-79d0-ae10-d4306bddc29c.jsonl"},"agent_status":"working","cwd":"/home/salem/coding/ERBareeq","focused":true,"foreground_cwd":"/home/salem/coding/ERBareeq","pane_id":"wJ:p5Y","revision":9,"scroll":{"max_offset_from_bottom":670,"offset_from_bottom":0,"viewport_rows":54},"tab_id":"wJ:t1H","terminal_id":"term_656fad1f3c60b63","terminal_title":"\u03c0 - ERBareeq: safely implement Postgres migration scope - ERBareeq","terminal_title_stripped":"\u03c0 - ERBareeq: safely implement Postgres migration scope - ERBareeq","tokens":{"pi_session_name":"ERBareeq: safely implement Postgres migration scope"},"workspace_id":"wJ"}],"protocol":16,"tabs":[{"agent_status":"idle","focused":false,"label":"1","number":24,"pane_count":1,"tab_id":"wJ:tR","workspace_id":"wJ"},{"agent_status":"working","focused":true,"label":"3","number":49,"pane_count":3,"tab_id":"wJ:t1H","workspace_id":"wJ"}],"version":"0.7.4","workspaces":[{"active_tab_id":"wJ:t1H","agent_status":"working","focused":true,"label":"ERBareeq","number":1,"pane_count":5,"tab_count":3,"workspace_id":"wJ"}]}}}""" } From 34a247afa4dfa1d6b82a43d868e49a7c06cdcecc Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 09:58:36 +0300 Subject: [PATCH 08/42] feat: add herdr terminal frame codec Codec for the herdr terminal stream plane: terminal.frame/closed parsing, ndjson framing, input/resize/scroll encoders, seq-gap disposition, and the control/observe stream command builder. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrMultiplexer.kt | 16 +++ .../multiplexer/HerdrTerminalStream.kt | 134 ++++++++++++++++++ .../multiplexer/HerdrMultiplexerTest.kt | 27 ++++ .../multiplexer/HerdrTerminalStreamTest.kt | 106 ++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt create mode 100644 android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt index 27004e66..fd01c2a0 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -65,6 +65,22 @@ object HerdrMultiplexer : Multiplexer { "while IFS= read -r _; do printf 'CHUCHU_SNAP_BEGIN\\n'; herdr api snapshot 2>/dev/null; " + "printf '\\nCHUCHU_SNAP_END\\n'; done" + fun terminalSessionCommand( + paneId: String, + cols: Int, + rows: Int, + mode: HerdrStreamMode, + ): String { + val command = when (mode) { + HerdrStreamMode.Control, + HerdrStreamMode.ControlTakeover, + -> "control" + HerdrStreamMode.Observe -> "observe" + } + val takeover = if (mode == HerdrStreamMode.ControlTakeover) " --takeover" else "" + return pathPrelude + "herdr terminal session $command ${shellQuote(paneId)} --cols $cols --rows $rows$takeover" + } + fun focusTabCommand(tabId: String): String = pathPrelude + "herdr tab focus ${shellQuote(tabId)}" diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt new file mode 100644 index 00000000..c34a98d6 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt @@ -0,0 +1,134 @@ +package com.jossephus.chuchu.service.multiplexer + +import java.util.Base64 +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +data class HerdrTerminalFrame( + val type: String = "", + val seq: Long = 0, + val full: Boolean = false, + val encoding: String = "", + val width: Int = 0, + val height: Int = 0, + val bytes: String = "", +) { + fun decodedBytes(): ByteArray = + try { + Base64.getDecoder().decode(bytes) + } catch (_: IllegalArgumentException) { + byteArrayOf() + } +} + +@Serializable +data class HerdrTerminalClosed( + val type: String = "", + val reason: String = "", +) + +sealed interface HerdrStreamMessage { + data class Frame(val value: HerdrTerminalFrame) : HerdrStreamMessage + + data class Closed(val value: HerdrTerminalClosed) : HerdrStreamMessage +} + +enum class HerdrStreamMode { + Control, + ControlTakeover, + Observe, +} + +@Serializable +enum class HerdrScrollDirection { + @SerialName("up") + Up, + + @SerialName("down") + Down, +} + +enum class FrameDisposition { + Apply, + Restart, +} + +private val herdrTerminalJson = Json { + encodeDefaults = true + ignoreUnknownKeys = true + isLenient = true +} + +fun parseHerdrStreamMessage(line: String): HerdrStreamMessage? = + try { + when (herdrTerminalJson.parseToJsonElement(line).jsonObject["type"]?.jsonPrimitive?.contentOrNull) { + "terminal.frame" -> HerdrStreamMessage.Frame(herdrTerminalJson.decodeFromString(line)) + "terminal.closed" -> HerdrStreamMessage.Closed(herdrTerminalJson.decodeFromString(line)) + else -> null + } + } catch (_: Exception) { + null + } + +fun appendHerdrNdjsonChunk(buffer: StringBuilder, chunk: String): List { + val lines = mutableListOf() + buffer.append(chunk) + + while (true) { + val newlineIndex = buffer.indexOf("\n") + if (newlineIndex < 0) return lines + lines += buffer.substring(0, newlineIndex) + buffer.delete(0, newlineIndex + 1) + } +} + +fun herdrInputTextJson(text: String): String = + herdrTerminalJson.encodeToString(HerdrTerminalInputText(text = text)) + +fun herdrInputBytesJson(bytes: ByteArray): String = + herdrTerminalJson.encodeToString(HerdrTerminalInputBytes(bytes = Base64.getEncoder().encodeToString(bytes))) + +fun herdrResizeJson(cols: Int, rows: Int): String = + herdrTerminalJson.encodeToString(HerdrTerminalResize(cols = cols, rows = rows)) + +fun herdrScrollJson(direction: HerdrScrollDirection, lines: Int): String = + herdrTerminalJson.encodeToString(HerdrTerminalScroll(direction = direction, lines = lines)) + +fun frameDisposition(lastSeq: Long?, frame: HerdrTerminalFrame): FrameDisposition = + if (frame.full || (lastSeq != null && frame.seq == lastSeq + 1)) { + FrameDisposition.Apply + } else { + FrameDisposition.Restart + } + +@Serializable +private data class HerdrTerminalInputText( + val type: String = "terminal.input", + val text: String, +) + +@Serializable +private data class HerdrTerminalInputBytes( + val type: String = "terminal.input", + val bytes: String, +) + +@Serializable +private data class HerdrTerminalResize( + val type: String = "terminal.resize", + val cols: Int, + val rows: Int, +) + +@Serializable +private data class HerdrTerminalScroll( + val type: String = "terminal.scroll", + val direction: HerdrScrollDirection, + val lines: Int, +) diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt index 724fd8d2..fa30cbc0 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt @@ -112,4 +112,31 @@ class HerdrMultiplexerTest { command, ) } + + @Test + fun terminalSessionCommandUsesRequestedModeAndQuotesPaneId() { + val prelude = "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + + assertEquals( + prelude + "herdr terminal session control 'pane-1' --cols 83 --rows 28", + HerdrMultiplexer.terminalSessionCommand("pane-1", 83, 28, HerdrStreamMode.Control), + ) + assertEquals( + prelude + "herdr terminal session control 'pane-1' --cols 83 --rows 28 --takeover", + HerdrMultiplexer.terminalSessionCommand("pane-1", 83, 28, HerdrStreamMode.ControlTakeover), + ) + assertEquals( + prelude + "herdr terminal session observe 'pane-1' --cols 83 --rows 28", + HerdrMultiplexer.terminalSessionCommand("pane-1", 83, 28, HerdrStreamMode.Observe), + ) + assertEquals( + prelude + "herdr terminal session control 'pane'\\''; touch /tmp/nope; '\\''' --cols 83 --rows 28", + HerdrMultiplexer.terminalSessionCommand( + "pane'; touch /tmp/nope; '", + 83, + 28, + HerdrStreamMode.Control, + ), + ) + } } diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt new file mode 100644 index 00000000..ce093a97 --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt @@ -0,0 +1,106 @@ +package com.jossephus.chuchu.service.multiplexer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HerdrTerminalStreamTest { + @Test + fun parsesTerminalFrameAndDecodesBytes() { + val message = parseHerdrStreamMessage( + """{"type":"terminal.frame","seq":42,"full":true,"encoding":"ansi","width":83,"height":28,"bytes":"SGVyZHIgZnJhbWUh"}""", + ) + + assertEquals( + HerdrStreamMessage.Frame( + HerdrTerminalFrame( + type = "terminal.frame", + seq = 42, + full = true, + encoding = "ansi", + width = 83, + height = 28, + bytes = "SGVyZHIgZnJhbWUh", + ), + ), + message, + ) + assertEquals("Herdr frame!", (message as HerdrStreamMessage.Frame).value.decodedBytes().decodeToString()) + assertEquals(0, HerdrTerminalFrame(bytes = "not base64").decodedBytes().size) + } + + @Test + fun parsesTerminalClosed() { + assertEquals( + HerdrStreamMessage.Closed( + HerdrTerminalClosed( + type = "terminal.closed", + reason = "already has an attached client; retry with --takeover", + ), + ), + parseHerdrStreamMessage( + """{"type":"terminal.closed","reason":"already has an attached client; retry with --takeover"}""", + ), + ) + } + + @Test + fun returnsNullForGarbageAndUnknownMessages() { + assertNull(parseHerdrStreamMessage("garbage")) + assertNull(parseHerdrStreamMessage("""{"type":"terminal.unknown"}""")) + } + + @Test + fun framesNdjsonAcrossChunksAndRetainsPartialLines() { + val buffer = StringBuilder() + + assertEquals(listOf("first"), appendHerdrNdjsonChunk(buffer, "first\nsec")) + assertEquals("sec", buffer.toString()) + assertEquals(listOf("second", "third"), appendHerdrNdjsonChunk(buffer, "ond\nthird\npartial")) + assertEquals("partial", buffer.toString()) + } + + @Test + fun encodesTerminalInputAndControlMessages() { + assertEquals( + """{"type":"terminal.input","text":"hello\nworld"}""", + herdrInputTextJson("hello\nworld"), + ) + assertEquals( + """{"type":"terminal.input","bytes":"AAECA/8="}""", + herdrInputBytesJson(byteArrayOf(0, 1, 2, 3, -1)), + ) + assertEquals( + """{"type":"terminal.resize","cols":166,"rows":56}""", + herdrResizeJson(166, 56), + ) + assertEquals( + """{"type":"terminal.scroll","direction":"up","lines":3}""", + herdrScrollJson(HerdrScrollDirection.Up, 3), + ) + assertEquals( + """{"type":"terminal.scroll","direction":"down","lines":2}""", + herdrScrollJson(HerdrScrollDirection.Down, 2), + ) + } + + @Test + fun determinesFrameDispositionFromSequence() { + assertEquals( + FrameDisposition.Apply, + frameDisposition(lastSeq = null, frame = HerdrTerminalFrame(seq = 10, full = true)), + ) + assertEquals( + FrameDisposition.Apply, + frameDisposition(lastSeq = 10, frame = HerdrTerminalFrame(seq = 11)), + ) + assertEquals( + FrameDisposition.Restart, + frameDisposition(lastSeq = 10, frame = HerdrTerminalFrame(seq = 12)), + ) + assertEquals( + FrameDisposition.Restart, + frameDisposition(lastSeq = null, frame = HerdrTerminalFrame(seq = 10)), + ) + } +} From 34b7ab356d218a209d680ad80dd6694c2395431c Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 10:09:36 +0300 Subject: [PATCH 09/42] feat: stream herdr panes into native terminals Adds HerdrPaneHost, which drives a per-pane herdr terminal.frame stream into a dedicated ghostty VT: control/observe modes with automatic read-only fallback when another client holds control, frame-header authoritative resize, seq-gap restart, and input/scroll uplink. The engine owns a map of pane hosts with ensure/retain/focus/takeover/stop lifecycle and a pure desiredHerdrPaneStreams reconciliation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrSnapshot.kt | 17 + .../multiplexer/HerdrTerminalStream.kt | 7 + .../chuchu/service/terminal/HerdrPaneHost.kt | 388 ++++++++++++++++++ .../service/terminal/TerminalSessionEngine.kt | 90 ++++ .../multiplexer/HerdrPaneStreamPolicyTest.kt | 61 +++ .../multiplexer/HerdrTerminalStreamTest.kt | 6 + 6 files changed, 569 insertions(+) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt create mode 100644 android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt index 469107cf..152d736d 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt @@ -51,6 +51,23 @@ data class HerdrTabLayout( val zoomed: Boolean = false, ) +fun desiredHerdrPaneStreams( + snapshot: HerdrSnapshot?, + nativeModeActive: Boolean, + foreground: Boolean, +): Set { + if (!nativeModeActive || !foreground || snapshot == null) return emptySet() + val layout = snapshot.layouts.firstOrNull { it.tabId == snapshot.focusedTabId } ?: return emptySet() + val focusedPaneId = snapshot.focusedPaneId ?: layout.focusedPaneId + if (layout.zoomed) return focusedPaneId?.let(::setOf) ?: emptySet() + val paneIds = layout.panes.map { it.paneId }.filter { it.isNotBlank() }.toMutableSet() + if (paneIds.size <= 6) return paneIds + val retained = LinkedHashSet(6) + focusedPaneId?.takeIf { it in paneIds }?.let(retained::add) + paneIds.asSequence().filterNot { it == focusedPaneId }.take(6 - retained.size).forEach(retained::add) + return retained +} + sealed interface HerdrControlState { data object Inactive : HerdrControlState diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt index c34a98d6..95b06a61 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt @@ -100,6 +100,13 @@ fun herdrResizeJson(cols: Int, rows: Int): String = fun herdrScrollJson(direction: HerdrScrollDirection, lines: Int): String = herdrTerminalJson.encodeToString(HerdrTerminalScroll(direction = direction, lines = lines)) +fun herdrScrollCommand(delta: Int): Pair = + if (delta >= 0) { + HerdrScrollDirection.Up to delta + } else { + HerdrScrollDirection.Down to -delta + } + fun frameDisposition(lastSeq: Long?, frame: HerdrTerminalFrame): FrameDisposition = if (frame.full || (lastSeq != null && frame.seq == lastSeq + 1)) { FrameDisposition.Apply diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt new file mode 100644 index 00000000..dd9c302d --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt @@ -0,0 +1,388 @@ +package com.jossephus.chuchu.service.terminal + +import com.jossephus.chuchu.service.multiplexer.FrameDisposition +import com.jossephus.chuchu.service.multiplexer.HerdrScrollDirection +import com.jossephus.chuchu.service.multiplexer.HerdrStreamMessage +import com.jossephus.chuchu.service.multiplexer.HerdrStreamMode +import com.jossephus.chuchu.service.multiplexer.appendHerdrNdjsonChunk +import com.jossephus.chuchu.service.multiplexer.frameDisposition +import com.jossephus.chuchu.service.multiplexer.herdrInputBytesJson +import com.jossephus.chuchu.service.multiplexer.herdrInputTextJson +import com.jossephus.chuchu.service.multiplexer.herdrResizeJson +import com.jossephus.chuchu.service.multiplexer.herdrScrollCommand +import com.jossephus.chuchu.service.multiplexer.herdrScrollJson +import com.jossephus.chuchu.service.multiplexer.parseHerdrStreamMessage +import com.jossephus.chuchu.service.ssh.NativeSshService +import java.util.concurrent.Executors +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +enum class HerdrPaneStreamStatus { + Connecting, + Streaming, + Error, + Disposed, +} + +data class HerdrPaneState( + val snapshot: TerminalSnapshot? = null, + val handle: Long = 0, + val status: HerdrPaneStreamStatus = HerdrPaneStreamStatus.Connecting, + val readOnly: Boolean = false, + val error: String? = null, +) + +class HerdrPaneHost( + val paneId: String, + private val sshFactory: () -> NativeSshService, + private val runCommand: (HerdrStreamMode, Int, Int) -> String, + private val scope: CoroutineScope, +) { + private enum class StreamEnd { + Restart, + ControlRefused, + } + + private data class PendingCommand( + val service: NativeSshService, + val json: String, + ) + + private val dispatcher: ExecutorCoroutineDispatcher = + Executors.newSingleThreadExecutor { r -> + Thread(r, "herdr-pane").apply { isDaemon = true } + } + .asCoroutineDispatcher() + private val bridge = GhosttyBridge() + private val pendingCommands = Channel(Channel.UNLIMITED) + private val _state = MutableStateFlow(HerdrPaneState()) + val state: StateFlow = _state.asStateFlow() + + @Volatile private var disposed = false + @Volatile private var streamService: NativeSshService? = null + private var streamJob: Job? = null + private var handle = 0L + private var cols = 80 + private var rows = 24 + private var cellWidth = 1 + private var cellHeight = 1 + private var vtCols = 80 + private var vtRows = 24 + private var streamMode = HerdrStreamMode.Observe + private var lastSnapshotAtMs = 0L + private var snapshotScheduled = false + + fun start(cols: Int, rows: Int, mode: HerdrStreamMode) { + scope.launch(dispatcher) { + if (disposed || cols <= 0 || rows <= 0) return@launch + this@HerdrPaneHost.cols = cols + this@HerdrPaneHost.rows = rows + streamMode = mode + if (handle == 0L) { + handle = bridge.nativeCreate(cols, rows, 0) + vtCols = cols + vtRows = rows + _state.value = _state.value.copy(handle = handle) + } + restartStream() + } + } + + fun setMode(mode: HerdrStreamMode) { + scope.launch(dispatcher) { + if (disposed || streamMode == mode) return@launch + streamMode = mode + _state.value = _state.value.copy(readOnly = mode == HerdrStreamMode.Observe, error = null) + restartStream() + } + } + + fun setViewport(cols: Int, rows: Int, cellWidthPx: Int, cellHeightPx: Int) { + scope.launch(dispatcher) { + if (disposed || cols <= 0 || rows <= 0 || cellWidthPx <= 0 || cellHeightPx <= 0) { + return@launch + } + this@HerdrPaneHost.cols = cols + this@HerdrPaneHost.rows = rows + cellWidth = cellWidthPx + cellHeight = cellHeightPx + sendCommand(herdrResizeJson(cols, rows), allowReadOnly = true) + } + } + + fun writeText(text: String) { + if (text.isEmpty()) return + scope.launch(dispatcher) { sendCommand(herdrInputTextJson(text)) } + } + + fun writeKey(key: Int, codepoint: Int, mods: Int, action: Int, utf8: String? = null) { + scope.launch(dispatcher) { + if (handle == 0L) return@launch + val encoded = bridge.nativeEncodeKey(handle, key, codepoint, mods, action, utf8) ?: return@launch + if (encoded.isNotEmpty()) sendCommand(herdrInputBytesJson(encoded)) + } + } + + fun writePaste(text: String) { + if (text.isEmpty()) return + scope.launch(dispatcher) { + if (handle == 0L) return@launch + val encoded = bridge.nativeEncodePaste(handle, text) ?: return@launch + if (encoded.isNotEmpty()) sendCommand(herdrInputBytesJson(encoded)) + } + } + + fun sendFocus(focused: Boolean) { + scope.launch(dispatcher) { + if (handle == 0L) return@launch + val encoded = bridge.nativeEncodeFocus(handle, focused) ?: return@launch + if (encoded.isNotEmpty()) sendCommand(herdrInputBytesJson(encoded)) + } + } + + fun scrollLines(lines: Int) { + if (lines == 0) return + scope.launch(dispatcher) { + val (direction, count) = herdrScrollCommand(lines) + sendCommand(herdrScrollJson(direction, count), allowReadOnly = true) + } + } + + fun setColorScheme(isDark: Boolean) { + scope.launch(dispatcher) { + if (handle != 0L) bridge.nativeSetColorScheme(handle, if (isDark) 1 else 0) + } + } + + fun setDefaultColors(fg: IntArray?, bg: IntArray?, cursor: IntArray?, palette: ByteArray?) { + scope.launch(dispatcher) { + if (handle == 0L) return@launch + bridge.nativeSetDefaultColors(handle, fg, bg, cursor, palette) + requestSnapshot(force = true) + } + } + + fun dispose() { + if (disposed) return + disposed = true + streamJob?.cancel() + streamJob = null + streamService?.let { service -> runCatching { service.close() } } + streamService = null + runBlocking { + withContext(dispatcher) { + snapshotScheduled = false + if (handle != 0L) { + bridge.nativeDestroy(handle) + handle = 0L + } + _state.value = _state.value.copy(handle = 0L, status = HerdrPaneStreamStatus.Disposed) + } + } + dispatcher.close() + } + + private fun restartStream() { + streamJob?.cancel() + streamJob = null + streamService?.let { service -> runCatching { service.close() } } + streamService = null + _state.value = + _state.value.copy( + status = HerdrPaneStreamStatus.Connecting, + readOnly = streamMode == HerdrStreamMode.Observe, + error = null, + ) + val mode = streamMode + streamJob = scope.launch(Dispatchers.IO) { stream(mode) } + } + + private suspend fun stream(initialMode: HerdrStreamMode) { + var mode = initialMode + var retryDelayMs = 2_000L + while (currentCoroutineContext().isActive && !disposed) { + var service: NativeSshService? = null + var failed = false + try { + setConnecting(mode) + service = sshFactory() + val (commandCols, commandRows) = withContext(dispatcher) { cols to rows } + if (!service.openExec(runCommand(mode, commandCols, commandRows))) { + throw IllegalStateException("Remote server did not open an exec channel") + } + streamService = service + retryDelayMs = 2_000L + when (readFrames(service, mode)) { + StreamEnd.Restart -> Unit + StreamEnd.ControlRefused -> { + mode = HerdrStreamMode.Observe + setReadOnly() + } + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + failed = true + setError(error.message ?: "Herdr terminal stream failed") + } finally { + if (streamService === service) streamService = null + service?.let { runCatching { it.close() } } + } + if (failed && !disposed) { + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(60_000L) + } + } + } + + private suspend fun readFrames(service: NativeSshService, mode: HerdrStreamMode): StreamEnd { + val buffer = StringBuilder() + var lastSeq: Long? = null + while (currentCoroutineContext().isActive && !disposed) { + writePendingCommands(service) + val chunk = service.read(8192) + if (chunk != null && chunk.isNotEmpty()) { + for (line in appendHerdrNdjsonChunk(buffer, chunk.toString(Charsets.UTF_8))) { + when (val message = parseHerdrStreamMessage(line)) { + is HerdrStreamMessage.Frame -> { + val frame = message.value + if (frameDisposition(lastSeq, frame) == FrameDisposition.Restart) { + return StreamEnd.Restart + } + applyFrame(frame.width, frame.height, frame.decodedBytes()) + lastSeq = frame.seq + } + is HerdrStreamMessage.Closed -> { + if ( + mode == HerdrStreamMode.Control && + message.value.reason.contains("attached client", ignoreCase = true) + ) { + return StreamEnd.ControlRefused + } + throw IllegalStateException(message.value.reason.ifBlank { "Herdr terminal stream closed" }) + } + null -> Unit + } + } + } else if (service.isChannelEof()) { + throw IllegalStateException("Herdr terminal stream ended") + } else { + delay(25) + } + } + throw CancellationException() + } + + private fun writePendingCommands(service: NativeSshService) { + while (true) { + val pending = pendingCommands.tryReceive().getOrNull() ?: return + if (pending.service === service) { + service.write((pending.json + "\n").toByteArray(Charsets.UTF_8)) + } + } + } + + private suspend fun applyFrame(width: Int, height: Int, bytes: ByteArray) { + withContext(dispatcher) { + if (handle == 0L) return@withContext + if (width != vtCols || height != vtRows) { + bridge.nativeResize(handle, width, height, cellWidth, cellHeight) + vtCols = width + vtRows = height + } + bridge.nativeWriteRemote(handle, bytes) + requestSnapshot() + _state.value = + _state.value.copy( + handle = handle, + status = HerdrPaneStreamStatus.Streaming, + readOnly = streamMode == HerdrStreamMode.Observe, + error = null, + ) + } + } + + private suspend fun setConnecting(mode: HerdrStreamMode) { + withContext(dispatcher) { + if (!disposed) { + _state.value = + _state.value.copy( + status = HerdrPaneStreamStatus.Connecting, + readOnly = mode == HerdrStreamMode.Observe, + error = null, + ) + } + } + } + + private suspend fun setReadOnly() { + withContext(dispatcher) { + if (!disposed) { + streamMode = HerdrStreamMode.Observe + _state.value = _state.value.copy(readOnly = true, error = null) + } + } + } + + private suspend fun setError(message: String) { + withContext(dispatcher) { + if (!disposed) { + _state.value = _state.value.copy(status = HerdrPaneStreamStatus.Error, error = message) + } + } + } + + private fun sendCommand(json: String, allowReadOnly: Boolean = false) { + if (disposed || (!allowReadOnly && _state.value.readOnly)) return + val service = streamService ?: return + pendingCommands.trySend(PendingCommand(service, json)) + } + + private fun requestSnapshot(force: Boolean = false) { + if (handle == 0L) return + val now = System.currentTimeMillis() + val elapsed = now - lastSnapshotAtMs + if (force || elapsed >= SNAPSHOT_INTERVAL_MS) { + snapshotScheduled = false + emitSnapshot() + lastSnapshotAtMs = now + return + } + if (snapshotScheduled) return + snapshotScheduled = true + val waitMs = (SNAPSHOT_INTERVAL_MS - elapsed).coerceAtLeast(1L) + scope.launch(dispatcher) { + delay(waitMs) + snapshotScheduled = false + if (handle == 0L) return@launch + emitSnapshot() + lastSnapshotAtMs = System.currentTimeMillis() + } + } + + private fun emitSnapshot() { + if (handle == 0L) return + val raw = bridge.nativeSnapshot(handle) + val images = TerminalSnapshot.parseImages(bridge.nativeSnapshotImages(handle)) + val snapshot = TerminalSnapshot.fromByteBuffer(raw, images) + _state.value = _state.value.copy(snapshot = snapshot, handle = handle) + } + + private companion object { + const val SNAPSHOT_INTERVAL_MS = 16L + } +} diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index 997a9b5b..99fac973 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -12,6 +12,7 @@ import com.jossephus.chuchu.service.mosh.NativeMoshService import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.multiplexer.HerdrMultiplexer import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot +import com.jossephus.chuchu.service.multiplexer.HerdrStreamMode import com.jossephus.chuchu.service.multiplexer.MultiplexerAvailability import com.jossephus.chuchu.service.multiplexer.MultiplexerCommandResult import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry @@ -156,6 +157,10 @@ class TerminalSessionEngine( private val _herdrState = MutableStateFlow(HerdrControlState.Inactive) val herdrState: StateFlow = _herdrState.asStateFlow() + private val _herdrPaneHosts = MutableStateFlow>(emptyMap()) + val herdrPanes: StateFlow> = _herdrPaneHosts.asStateFlow() + private var herdrFocusedPaneId: String? = null + data class DefaultColors( val fg: IntArray?, val bg: IntArray?, @@ -185,6 +190,7 @@ class TerminalSessionEngine( ) { disconnectRequested = false stopHerdrStream() + stopHerdrPaneStreams() val params = ConnectionParams( host = host, @@ -603,6 +609,7 @@ class TerminalSessionEngine( reconnectJob?.cancel() reconnectJob = null stopHerdrStream() + stopHerdrPaneStreams() lastConnectionParams = null cancelHostKeyPrompt() scope.launch(dispatcher) { @@ -635,6 +642,88 @@ class TerminalSessionEngine( dispatcher.close() } + fun ensureHerdrPaneStream( + paneId: String, + cols: Int, + rows: Int, + cellWidthPx: Int, + cellHeightPx: Int, + focused: Boolean, + ) { + scope.launch(dispatcher) { + val params = lastConnectionParams ?: return@launch + if (!isHerdrSshSession(params)) return@launch + val existing = _herdrPaneHosts.value[paneId] + if (existing != null) { + existing.setViewport(cols, rows, cellWidthPx, cellHeightPx) + return@launch + } + val host = + HerdrPaneHost( + paneId = paneId, + sshFactory = { + NativeSshService(hostKeyPolicy = ::verifyHostKey).also { service -> + service.connect( + host = params.host, + port = params.port, + username = params.username, + authMethod = params.authMethod, + password = if (params.authMethod == AuthMethod.Password) params.password else "", + publicKeyOpenSsh = params.publicKeyOpenSsh, + privateKeyPem = params.privateKeyPem, + keyPassphrase = params.keyPassphrase, + ) + } + }, + runCommand = { mode, commandCols, commandRows -> + HerdrMultiplexer.terminalSessionCommand(paneId, commandCols, commandRows, mode) + }, + scope = scope, + ) + _herdrPaneHosts.value = _herdrPaneHosts.value + (paneId to host) + host.start(cols, rows, if (focused) HerdrStreamMode.Control else HerdrStreamMode.Observe) + host.setViewport(cols, rows, cellWidthPx, cellHeightPx) + if (focused) herdrFocusedPaneId = paneId + } + } + + fun retainHerdrPaneStreams(paneIds: Set) { + scope.launch(dispatcher) { + val params = lastConnectionParams ?: return@launch + if (!isHerdrSshSession(params)) return@launch + val removed = _herdrPaneHosts.value.filterKeys { it !in paneIds } + if (removed.isEmpty()) return@launch + removed.values.forEach(HerdrPaneHost::dispose) + _herdrPaneHosts.value = _herdrPaneHosts.value - removed.keys + if (herdrFocusedPaneId !in paneIds) herdrFocusedPaneId = null + } + } + + fun setHerdrFocusedPaneStream(paneId: String?) { + scope.launch(dispatcher) { + val params = lastConnectionParams ?: return@launch + if (!isHerdrSshSession(params)) return@launch + val previous = herdrFocusedPaneId + if (previous != paneId) _herdrPaneHosts.value[previous]?.setMode(HerdrStreamMode.Observe) + _herdrPaneHosts.value[paneId]?.setMode(HerdrStreamMode.Control) + herdrFocusedPaneId = paneId + } + } + + fun herdrTakeoverFocusedPane() { + scope.launch(dispatcher) { + val params = lastConnectionParams ?: return@launch + if (!isHerdrSshSession(params)) return@launch + _herdrPaneHosts.value[herdrFocusedPaneId]?.setMode(HerdrStreamMode.ControlTakeover) + } + } + + private fun stopHerdrPaneStreams() { + _herdrPaneHosts.value.values.forEach(HerdrPaneHost::dispose) + _herdrPaneHosts.value = emptyMap() + herdrFocusedPaneId = null + } + private fun cancelHostKeyPrompt() { hostKeyDecision?.cancel() hostKeyDecision = null @@ -1202,6 +1291,7 @@ class TerminalSessionEngine( return } stopHerdrStream() + stopHerdrPaneStreams() if (reconnectJob?.isActive == true) return reconnectJob = scope.launch(dispatcher) { diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt new file mode 100644 index 00000000..21a58d9c --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt @@ -0,0 +1,61 @@ +package com.jossephus.chuchu.service.multiplexer + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HerdrPaneStreamPolicyTest { + @Test + fun returnsEmptyOutsideForegroundNativeModeOrWithoutSnapshot() { + val snapshot = snapshot(panes = listOf("p1")) + + assertEquals(emptySet(), desiredHerdrPaneStreams(null, true, true)) + assertEquals(emptySet(), desiredHerdrPaneStreams(HerdrSnapshot(), true, true)) + assertEquals(emptySet(), desiredHerdrPaneStreams(snapshot, false, true)) + assertEquals(emptySet(), desiredHerdrPaneStreams(snapshot, true, false)) + } + + @Test + fun returnsFocusedTabLayoutPanes() { + assertEquals( + linkedSetOf("p1", "p2"), + desiredHerdrPaneStreams(snapshot(panes = listOf("p1", "p2")), true, true), + ) + } + + @Test + fun returnsOnlyFocusedPaneForZoomedLayout() { + assertEquals( + setOf("p2"), + desiredHerdrPaneStreams(snapshot(panes = listOf("p1", "p2"), zoomed = true), true, true), + ) + } + + @Test + fun capsPaneStreamsWhileRetainingFocusedPane() { + val panes = (1..8).map { "p$it" } + + val desired = desiredHerdrPaneStreams(snapshot(panes, focusedPaneId = "p8"), true, true) + + assertEquals(6, desired.size) + assertEquals(true, "p8" in desired) + assertEquals(linkedSetOf("p8", "p1", "p2", "p3", "p4", "p5"), desired) + } + + private fun snapshot( + panes: List, + focusedPaneId: String = "p2", + zoomed: Boolean = false, + ): HerdrSnapshot = + HerdrSnapshot( + focusedTabId = "tab", + focusedPaneId = focusedPaneId, + layouts = listOf( + HerdrTabLayout( + tabId = "tab", + focusedPaneId = focusedPaneId, + panes = panes.map { HerdrLayoutPane(paneId = it) }, + zoomed = zoomed, + ), + ), + ) +} diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt index ce093a97..c8638d40 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt @@ -103,4 +103,10 @@ class HerdrTerminalStreamTest { frameDisposition(lastSeq = null, frame = HerdrTerminalFrame(seq = 10)), ) } + + @Test + fun mapsPositiveScrollDeltaToHistoryUp() { + assertEquals(HerdrScrollDirection.Up to 3, herdrScrollCommand(3)) + assertEquals(HerdrScrollDirection.Down to 2, herdrScrollCommand(-2)) + } } From 9e5bafcc134e56198b829a61d9f77a4ccceb688e Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 10:14:45 +0300 Subject: [PATCH 10/42] feat: route input to the focused herdr pane Repository tracks the focused herdr pane and routes key/text/paste/scroll/ focus to its pane host (suppressing mouse and full-canvas resize in native mode), reconciles visible pane streams from the layout, and follows server-side focus changes. Engine flattens pane-host state into herdrPaneStates; ViewModel exposes herdrNativeState + pane tap/viewport/ scroll/takeover handlers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../chuchu/service/terminal/TabSpec.kt | 3 + .../service/terminal/TerminalSessionEngine.kt | 19 ++++ .../terminal/TerminalSessionRepository.kt | 100 ++++++++++++++++-- .../ui/screens/Terminal/TerminalViewModel.kt | 56 ++++++++++ 4 files changed, 172 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt index 12c68bf9..dfc5370c 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt @@ -51,6 +51,9 @@ data class TabSpec( transport != Transport.Mosh && transport != Transport.LocalShell + val usesHerdrNativeMode: Boolean + get() = multiplexer == MultiplexerType.Herdr && usesRuntimeMultiplexer + companion object { fun fromHostProfile( host: HostProfile, diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index 99fac973..d5a0bc62 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -34,8 +34,14 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -159,6 +165,19 @@ class TerminalSessionEngine( private val _herdrPaneHosts = MutableStateFlow>(emptyMap()) val herdrPanes: StateFlow> = _herdrPaneHosts.asStateFlow() + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + val herdrPaneStates: StateFlow> = + herdrPanes + .flatMapLatest { paneHosts -> + if (paneHosts.isEmpty()) { + flowOf(emptyMap()) + } else { + combine(paneHosts.map { (paneId, host) -> host.state.map { paneId to it } }) { + states -> states.associate { it } + } + } + } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) private var herdrFocusedPaneId: String? = null data class DefaultColors( diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt index 37aed75f..bbd0adb7 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt @@ -9,6 +9,7 @@ import com.jossephus.chuchu.model.Transport import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry import com.jossephus.chuchu.service.multiplexer.RemoteMultiplexerSession +import com.jossephus.chuchu.service.multiplexer.desiredHerdrPaneStreams import com.jossephus.chuchu.service.ssh.HostKeyStore import com.jossephus.chuchu.service.ssh.TailscaleStatusChecker import java.util.UUID @@ -66,6 +67,9 @@ class TerminalSessionRepository private constructor(application: Application) { private val _activeTabId = MutableStateFlow(null) val activeTabId: StateFlow = _activeTabId.asStateFlow() + private val _herdrFocusedPaneId = MutableStateFlow(null) + val herdrFocusedPaneId: StateFlow = _herdrFocusedPaneId.asStateFlow() + val activeTab: StateFlow = combine(_tabs, _activeTabId) { tabs, id -> tabs.firstOrNull { it.id == id } } .stateIn(scope, SharingStarted.Eagerly, null) @@ -134,6 +138,7 @@ class TerminalSessionRepository private constructor(application: Application) { } .collect { pairs -> updateHerdrCadence() + updateHerdrPaneStreams() val anyAlive = pairs.any { (_, state) -> state.status == SessionStatus.Connecting || @@ -157,11 +162,13 @@ class TerminalSessionRepository private constructor(application: Application) { fun attachClient() { attachedClients += 1 updateHerdrCadence() + updateHerdrPaneStreams() } fun detachClient() { attachedClients = (attachedClients - 1).coerceAtLeast(0) updateHerdrCadence() + updateHerdrPaneStreams() } private fun currentNotificationLabel(): String { @@ -258,6 +265,7 @@ class TerminalSessionRepository private constructor(application: Application) { multiplexerCreateIfMissing = spec.multiplexerCreateIfMissing, ) updateHerdrCadence() + updateHerdrPaneStreams() return tab } @@ -265,6 +273,7 @@ class TerminalSessionRepository private constructor(application: Application) { if (_tabs.value.any { it.id == id }) { _activeTabId.value = id updateHerdrCadence() + updateHerdrPaneStreams() } } @@ -280,6 +289,7 @@ class TerminalSessionRepository private constructor(application: Application) { _activeTabId.value = nextSameHost?.id ?: remaining.firstOrNull()?.id } updateHerdrCadence() + updateHerdrPaneStreams() tab.engine.dispose() } @@ -334,6 +344,7 @@ class TerminalSessionRepository private constructor(application: Application) { herdrStateJobs.values.forEach(Job::cancel) herdrStateJobs.clear() herdrCadenceByTab.clear() + _herdrFocusedPaneId.value = null tabs.forEach { herdrAgentNotifier.removeTab(it.id) } tabs.forEach { it.engine.dispose() } } @@ -352,6 +363,7 @@ class TerminalSessionRepository private constructor(application: Application) { enabled = settingsRepository.herdrNotificationsEnabled.value, ) } + updateHerdrPaneStreams() } } } @@ -381,8 +393,68 @@ class TerminalSessionRepository private constructor(application: Application) { } } + private fun updateHerdrPaneStreams() { + val activeTab = _tabs.value.firstOrNull { it.id == _activeTabId.value } + _tabs.value.forEach { tab -> + if (tab.id == activeTab?.id && tab.spec.usesHerdrNativeMode) { + val snapshot = (tab.engine.herdrState.value as? HerdrControlState.Active)?.snapshot + tab.engine.retainHerdrPaneStreams( + desiredHerdrPaneStreams( + snapshot = snapshot, + nativeModeActive = true, + foreground = attachedClients > 0, + ), + ) + if (snapshot != null && _herdrFocusedPaneId.value != snapshot.focusedPaneId) { + setHerdrNativeFocus(snapshot.focusedPaneId) + } + } else { + tab.engine.retainHerdrPaneStreams(emptySet()) + } + } + if (activeTab?.spec?.usesHerdrNativeMode != true) { + _herdrFocusedPaneId.value = null + } + } + private fun activeEngine(): TerminalSessionEngine? = activeTab.value?.engine + private fun focusedHerdrPaneHost(): HerdrPaneHost? { + val tab = activeTab.value ?: return null + if (!tab.spec.usesHerdrNativeMode) return null + return tab.engine.herdrPanes.value[_herdrFocusedPaneId.value] + } + + fun setHerdrNativeFocus(paneId: String?) { + _herdrFocusedPaneId.value = paneId + activeEngine()?.setHerdrFocusedPaneStream(paneId) + } + + fun herdrTakeover() { + activeEngine()?.herdrTakeoverFocusedPane() + } + + fun resizeHerdrPane( + paneId: String, + cols: Int, + rows: Int, + cellWidth: Int, + cellHeight: Int, + ) { + activeEngine()?.ensureHerdrPaneStream( + paneId = paneId, + cols = cols, + rows = rows, + cellWidthPx = cellWidth, + cellHeightPx = cellHeight, + focused = paneId == _herdrFocusedPaneId.value, + ) + } + + fun scrollHerdrPane(paneId: String, delta: Int) { + activeEngine()?.herdrPanes?.value?.get(paneId)?.scrollLines(delta) + } + private fun sessionKeyFor(tab: TabSession): String = if (tab.spec.transport == Transport.LocalShell) { "local-shell:${tab.id}" @@ -407,30 +479,37 @@ class TerminalSessionRepository private constructor(application: Application) { screenWidth: Int, screenHeight: Int, ) { + if (activeTab.value?.spec?.usesHerdrNativeMode == true) return activeEngine()?.resize(cols, rows, cellWidth, cellHeight, screenWidth, screenHeight) } fun scroll(delta: Int, x: Float, y: Float) { - activeEngine()?.scroll(delta, x, y) + focusedHerdrPaneHost()?.scrollLines(delta) ?: activeEngine()?.scroll(delta, x, y) } fun scrollToActive() { + if (activeTab.value?.spec?.usesHerdrNativeMode == true) return activeEngine()?.scrollToActive() } fun writeKey(key: Int, codepoint: Int, mods: Int, action: Int, utf8: String? = null) { - activeEngine()?.writeKey(key, codepoint, mods, action, utf8) + focusedHerdrPaneHost()?.writeKey(key, codepoint, mods, action, utf8) + ?: activeEngine()?.writeKey(key, codepoint, mods, action, utf8) } fun writeText(text: String) { - activeEngine()?.writeText(text) + focusedHerdrPaneHost()?.writeText(text) ?: activeEngine()?.writeText(text) } fun writePaste(text: String) { - activeEngine()?.writePaste(text) + focusedHerdrPaneHost()?.writePaste(text) ?: activeEngine()?.writePaste(text) } fun sendFocusEvent(focused: Boolean) { + if (activeTab.value?.spec?.usesHerdrNativeMode == true) { + focusedHerdrPaneHost()?.sendFocus(focused) + return + } activeEngine()?.sendFocusEvent(focused) } @@ -443,15 +522,24 @@ class TerminalSessionRepository private constructor(application: Application) { anyButtonPressed: Boolean, trackLastCell: Boolean, ) { + if (activeTab.value?.spec?.usesHerdrNativeMode == true) return activeEngine()?.sendMouseEvent(action, button, mods, x, y, anyButtonPressed, trackLastCell) } fun setColorScheme(isDark: Boolean) { - _tabs.value.forEach { it.engine.setColorScheme(isDark) } + _tabs.value.forEach { tab -> + tab.engine.setColorScheme(isDark) + tab.engine.herdrPanes.value.values.forEach { it.setColorScheme(isDark) } + } } fun setDefaultColors(fg: IntArray?, bg: IntArray?, cursor: IntArray?, palette: ByteArray?) { - _tabs.value.forEach { it.engine.setDefaultColors(fg, bg, cursor, palette) } + _tabs.value.forEach { tab -> + tab.engine.setDefaultColors(fg, bg, cursor, palette) + tab.engine.herdrPanes.value.values.forEach { + it.setDefaultColors(fg, bg, cursor, palette) + } + } } fun respondToHostKey(accepted: Boolean) { diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index 8ec84865..7b51d213 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -13,11 +13,13 @@ import com.jossephus.chuchu.model.HostProfile import com.jossephus.chuchu.model.MultiplexerType import com.jossephus.chuchu.model.Transport import com.jossephus.chuchu.service.multiplexer.HerdrControlState +import com.jossephus.chuchu.service.multiplexer.HerdrTabLayout import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry import com.jossephus.chuchu.service.multiplexer.MultiplexerCommandResult import com.jossephus.chuchu.service.multiplexer.RemoteMultiplexerSession import com.jossephus.chuchu.service.ssh.TailscaleStatusChecker import com.jossephus.chuchu.service.terminal.HostKeyPrompt +import com.jossephus.chuchu.service.terminal.HerdrPaneState import com.jossephus.chuchu.service.terminal.SessionState import com.jossephus.chuchu.service.terminal.TabSession import com.jossephus.chuchu.service.terminal.TabSpec @@ -98,6 +100,28 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application } } .stateIn(viewModelScope, SharingStarted.Eagerly, HerdrUiState()) + val herdrNativeState: StateFlow = + activeTab + .flatMapLatest { tab -> + if (tab?.spec?.usesHerdrNativeMode == true) { + combine( + tab.engine.herdrState, + tab.engine.herdrPaneStates, + sessionRepository.herdrFocusedPaneId, + ) { control, panes, focusedPaneId -> + val snapshot = (control as? HerdrControlState.Active)?.snapshot + HerdrNativeUiState( + enabled = true, + layout = snapshot?.layouts?.firstOrNull { it.tabId == snapshot.focusedTabId }, + panes = panes, + focusedPaneId = focusedPaneId, + ) + } + } else { + flowOf(HerdrNativeUiState()) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, HerdrNativeUiState()) val hosts: StateFlow> = hostRepository.observeAll().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) val hostsLoaded: StateFlow = @@ -441,6 +465,31 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application runHerdrCommand { it.herdrFocusPane(paneId) } } + fun onHerdrPaneTap(paneId: String) { + sessionRepository.setHerdrNativeFocus(paneId) + onHerdrFocusPane(paneId) + } + + fun onHerdrPaneViewport( + paneId: String, + cols: Int, + rows: Int, + cellWidth: Int, + cellHeight: Int, + wPx: Int, + hPx: Int, + ) { + sessionRepository.resizeHerdrPane(paneId, cols, rows, cellWidth, cellHeight) + } + + fun onHerdrPaneScroll(paneId: String, delta: Int) { + sessionRepository.scrollHerdrPane(paneId, delta) + } + + fun onHerdrTakeover() { + sessionRepository.herdrTakeover() + } + fun onHerdrCreateTab(workspaceId: String) { runHerdrCommand { it.herdrCreateTab(workspaceId) } } @@ -816,3 +865,10 @@ data class HerdrUiState( val enabled: Boolean = false, val actionError: String? = null, ) + +data class HerdrNativeUiState( + val enabled: Boolean = false, + val layout: HerdrTabLayout? = null, + val panes: Map = emptyMap(), + val focusedPaneId: String? = null, +) From d97b1f13a280d8ecba796a7299abd83bc4652f4a Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 10:21:56 +0300 Subject: [PATCH 11/42] feat: render herdr tabs as native splits Adds HerdrSplitLayout, which scales herdr's pane geometry onto the screen and renders one native terminal per pane (focused pane interactive, others tap-to-focus), with connecting/error placeholders, zoom, and a read-only takeover chip. TerminalScreen shows it in place of the single canvas when native mode is active; herdr native launch runs a keepalive channel instead of the TUI so the splits own the screen. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrMultiplexer.kt | 9 + .../ui/screens/Terminal/HerdrSplitLayout.kt | 179 ++++++++++++++++++ .../ui/screens/Terminal/TerminalScreen.kt | 102 ++++++---- 3 files changed, 258 insertions(+), 32 deletions(-) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt index fd01c2a0..1817e18d 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -55,6 +55,15 @@ object HerdrMultiplexer : Multiplexer { } } + fun nativeModeLaunchCommand(sessionName: String): String { + val target = shellQuote(sessionName) + val pattern = shellQuote("\"name\":\"$sessionName\"") + return pathPrelude + + "if ! herdr session list --json 2>/dev/null | grep -Fq -- $pattern; then " + + "printf 'herdr session %s is not running on this host\\n' $target; exit 1; fi; " + + "while :; do sleep 3600; done" + } + override fun defaultSessionName( remoteSessions: Collection, localSessionNames: Collection, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt new file mode 100644 index 00000000..18deef2e --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt @@ -0,0 +1,179 @@ +package com.jossephus.chuchu.ui.screens.Terminal + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.jossephus.chuchu.service.terminal.HerdrPaneStreamStatus +import com.jossephus.chuchu.ui.components.ChuButton +import com.jossephus.chuchu.ui.components.ChuButtonVariant +import com.jossephus.chuchu.ui.components.ChuText +import com.jossephus.chuchu.ui.terminal.TerminalCanvas +import com.jossephus.chuchu.ui.terminal.TerminalSelection +import com.jossephus.chuchu.ui.terminal.TerminalSelectionState +import com.jossephus.chuchu.ui.theme.ChuColorPalette +import com.jossephus.chuchu.ui.theme.ChuTypography +import com.jossephus.chuchu.ui.theme.GhosttyTheme + +@Composable +fun HerdrSplitLayout( + state: HerdrNativeUiState, + fontSizeSp: Float, + ghosttyTheme: GhosttyTheme?, + colors: ChuColorPalette, + selection: TerminalSelection?, + onSelectionChange: (TerminalSelection?) -> Unit, + onSelectionChanged: (TerminalSelectionState?) -> Unit, + onPaneTap: (String) -> Unit, + onPaneViewport: (String, Int, Int, Int, Int, Int, Int) -> Unit, + onPaneScroll: (String, Int) -> Unit, + onTakeover: () -> Unit, + requestInputFocus: () -> Unit, + modifier: Modifier = Modifier, +) { + val layout = state.layout ?: return + val focusedPaneId = state.focusedPaneId ?: layout.focusedPaneId + val panes = if (layout.zoomed) { + layout.panes.filter { it.paneId == focusedPaneId } + } else { + layout.panes + } + + BoxWithConstraints(modifier = modifier) { + panes.forEach { layoutPane -> + val paneModifier = if (layout.zoomed) { + Modifier.fillMaxSize() + } else { + Modifier + .offset( + x = scaledOffset(layoutPane.rect.x - layout.area.x, layout.area.width, maxWidth), + y = scaledOffset(layoutPane.rect.y - layout.area.y, layout.area.height, maxHeight), + ) + .width(scaledSize(layoutPane.rect.width, layout.area.width, maxWidth)) + .height(scaledSize(layoutPane.rect.height, layout.area.height, maxHeight)) + .padding(0.5.dp) + } + HerdrSplitPane( + paneId = layoutPane.paneId, + paneState = state.panes[layoutPane.paneId], + focused = layoutPane.paneId == focusedPaneId, + fontSizeSp = fontSizeSp, + ghosttyTheme = ghosttyTheme, + colors = colors, + selection = selection, + onSelectionChange = onSelectionChange, + onSelectionChanged = onSelectionChanged, + onPaneTap = onPaneTap, + onPaneViewport = onPaneViewport, + onPaneScroll = onPaneScroll, + onTakeover = onTakeover, + requestInputFocus = requestInputFocus, + modifier = paneModifier, + ) + } + } +} + +@Composable +private fun HerdrSplitPane( + paneId: String, + paneState: com.jossephus.chuchu.service.terminal.HerdrPaneState?, + focused: Boolean, + fontSizeSp: Float, + ghosttyTheme: GhosttyTheme?, + colors: ChuColorPalette, + selection: TerminalSelection?, + onSelectionChange: (TerminalSelection?) -> Unit, + onSelectionChanged: (TerminalSelectionState?) -> Unit, + onPaneTap: (String) -> Unit, + onPaneViewport: (String, Int, Int, Int, Int, Int, Int) -> Unit, + onPaneScroll: (String, Int) -> Unit, + onTakeover: () -> Unit, + requestInputFocus: () -> Unit, + modifier: Modifier, +) { + val typography = ChuTypography.current + val borderColor = if (focused) colors.accent else colors.border.copy(alpha = 0.55f) + val borderWidth = if (focused) 2.dp else 1.dp + Box(modifier = modifier.border(borderWidth, borderColor)) { + val snapshot = paneState?.snapshot + if (snapshot == null) { + val message = if (paneState?.status == HerdrPaneStreamStatus.Error) { + paneState.error ?: "pane stream failed" + } else { + "connecting…" + } + ChuText( + message, + style = typography.labelSmall, + color = if (paneState?.status == HerdrPaneStreamStatus.Error) colors.error else colors.textMuted, + modifier = Modifier.align(Alignment.Center), + ) + } else { + TerminalCanvas( + snapshot = snapshot, + fitSnapshotToCanvas = true, + terminalHandle = paneState.handle, + enableGestures = focused, + fontSizeSp = fontSizeSp, + cursorColor = ghosttyTheme?.cursorColor ?: Color.White.copy(alpha = 0.28f), + cursorTextColor = ghosttyTheme?.cursorText, + selectionBackgroundColor = ghosttyTheme?.selectionBackground ?: colors.accent.copy(alpha = 0.45f), + selectionForegroundColor = ghosttyTheme?.selectionForeground ?: colors.onAccent, + selection = if (focused) selection else null, + onSelectionChange = if (focused) onSelectionChange else { _: TerminalSelection? -> }, + onResize = { cols, rows, cellWidth, cellHeight, widthPx, heightPx -> + onPaneViewport(paneId, cols, rows, cellWidth, cellHeight, widthPx, heightPx) + }, + onTap = { + onPaneTap(paneId) + requestInputFocus() + }, + onPrimaryClick = { _, _ -> }, + onAppSelectionDrag = { _, _, _ -> }, + onScroll = { delta, _, _ -> onPaneScroll(paneId, delta) }, + onZoom = {}, + onSelectionChanged = if (focused) onSelectionChanged else { _: TerminalSelectionState? -> }, + modifier = Modifier.fillMaxSize(), + ) + } + if (!focused) { + Box( + modifier = Modifier.fillMaxSize().clickable { + onPaneTap(paneId) + requestInputFocus() + }, + ) + } + if (paneState?.readOnly == true && focused) { + ChuButton( + onClick = onTakeover, + variant = ChuButtonVariant.Ghost, + bracketed = true, + borderColor = colors.textMuted, + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp), + modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), + ) { + ChuText("read-only — tap to take over", style = typography.labelSmall, color = colors.textMuted) + } + } + } +} + +private fun scaledOffset(value: Int, extent: Int, container: Dp): Dp = + if (extent > 0) container * (value.toFloat() / extent) else 0.dp + +private fun scaledSize(value: Int, extent: Int, container: Dp): Dp = + if (extent > 0) (container * (value.toFloat() / extent)).coerceAtLeast(0.dp) else 0.dp diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index fc80b1b6..90435b45 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -408,6 +408,10 @@ fun TerminalScreen( } val multiplexerState by vm.multiplexerState.collectAsStateWithLifecycle() val herdrUiState by vm.herdrUiState.collectAsStateWithLifecycle() + val herdrNativeState by vm.herdrNativeState.collectAsStateWithLifecycle() + var herdrSelection by remember(herdrNativeState.focusedPaneId) { mutableStateOf(null) } + var herdrSelectionState by + remember(herdrNativeState.focusedPaneId) { mutableStateOf(null) } LaunchedEffect(terminalFontSizeSp) { settingsRepo.setTerminalFontSize(terminalFontSizeSp) @@ -808,7 +812,7 @@ fun TerminalScreen( SessionStatus.Reconnecting -> { val isReconnecting = sessionState.status == SessionStatus.Reconnecting val snapshot = sessionState.snapshot - if (snapshot != null) { + if (snapshot != null || herdrNativeState.enabled) { var modifierState by remember { mutableStateOf(ModifierState()) } val inputViewRef = remember { mutableStateOf(null) } var menuSize by remember { mutableStateOf(IntSize.Zero) } @@ -1269,37 +1273,71 @@ fun TerminalScreen( } } else { Box(modifier = Modifier.weight(1f)) { - TerminalCanvas( - snapshot = snapshot, - fontSizeSp = terminalFontSizeSp, - cursorColor = - ghosttyTheme?.cursorColor - ?: Color.White.copy(alpha = 0.28f), - cursorTextColor = ghosttyTheme?.cursorText, - selectionBackgroundColor = - ghosttyTheme?.selectionBackground - ?: colors.accent.copy(alpha = 0.45f), - selectionForegroundColor = - ghosttyTheme?.selectionForeground ?: colors.onAccent, - selection = selection, - onSelectionChange = { selection = it }, - terminalHandle = sessionState.handle, - modifier = Modifier.fillMaxSize(), - onResize = vm::onCanvasSizeChanged, - onTap = requestInputFocus, - onPrimaryClick = vm::onPrimaryMouseClick, - onAppSelectionDrag = vm::onAppSelectionDrag, - onScroll = vm::onScroll, - onZoom = { zoomFactor -> - terminalFontSizeSp = - (terminalFontSizeSp * zoomFactor) - .coerceIn( - SettingsRepository.MIN_TERMINAL_FONT_SIZE, - SettingsRepository.MAX_TERMINAL_FONT_SIZE, - ) - }, - onSelectionChanged = { state -> selectionState = state }, - ) + when { + herdrNativeState.enabled && + herdrNativeState.layout?.panes?.isNotEmpty() == true -> { + HerdrSplitLayout( + state = herdrNativeState, + fontSizeSp = terminalFontSizeSp, + ghosttyTheme = ghosttyTheme, + colors = colors, + selection = herdrSelection, + onSelectionChange = { herdrSelection = it }, + onSelectionChanged = { herdrSelectionState = it }, + onPaneTap = vm::onHerdrPaneTap, + onPaneViewport = vm::onHerdrPaneViewport, + onPaneScroll = vm::onHerdrPaneScroll, + onTakeover = vm::onHerdrTakeover, + requestInputFocus = requestInputFocus, + modifier = Modifier.fillMaxSize(), + ) + } + + herdrNativeState.enabled -> { + ChuText( + "preparing herdr…", + style = typography.labelSmall, + color = colors.textMuted, + modifier = Modifier.align(Alignment.Center), + ) + } + + else -> { + snapshot?.let { terminalSnapshot -> + TerminalCanvas( + snapshot = terminalSnapshot, + fontSizeSp = terminalFontSizeSp, + cursorColor = + ghosttyTheme?.cursorColor + ?: Color.White.copy(alpha = 0.28f), + cursorTextColor = ghosttyTheme?.cursorText, + selectionBackgroundColor = + ghosttyTheme?.selectionBackground + ?: colors.accent.copy(alpha = 0.45f), + selectionForegroundColor = + ghosttyTheme?.selectionForeground ?: colors.onAccent, + selection = selection, + onSelectionChange = { selection = it }, + terminalHandle = sessionState.handle, + modifier = Modifier.fillMaxSize(), + onResize = vm::onCanvasSizeChanged, + onTap = requestInputFocus, + onPrimaryClick = vm::onPrimaryMouseClick, + onAppSelectionDrag = vm::onAppSelectionDrag, + onScroll = vm::onScroll, + onZoom = { zoomFactor -> + terminalFontSizeSp = + (terminalFontSizeSp * zoomFactor) + .coerceIn( + SettingsRepository.MIN_TERMINAL_FONT_SIZE, + SettingsRepository.MAX_TERMINAL_FONT_SIZE, + ) + }, + onSelectionChanged = { state -> selectionState = state }, + ) + } + } + } Row( modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), From fac3d4c0831a7290a14ca48c7df99d06bbe0d8cb Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 10:21:56 +0300 Subject: [PATCH 12/42] feat: add per-host herdr native splits toggle Persists a per-host herdrNativeMode flag (default on, Room v12 migration) that gates native splits vs classic TUI attach, threaded through TabSpec/ConnectionParams and exposed as a "native splits (beta)" switch in Add Server when herdr is selected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../12.json | 185 ++++++++++++++++++ .../jossephus/chuchu/data/db/AppDatabase.kt | 3 +- .../com/jossephus/chuchu/model/HostProfile.kt | 2 + .../chuchu/service/terminal/TabSpec.kt | 4 +- .../service/terminal/TerminalSessionEngine.kt | 6 + .../terminal/TerminalSessionRepository.kt | 2 + .../ui/screens/AddServer/AddServerScreen.kt | 14 ++ .../screens/AddServer/AddServerViewModel.kt | 7 + 8 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 android/app/schemas/com.jossephus.chuchu.data.db.AppDatabase/12.json diff --git a/android/app/schemas/com.jossephus.chuchu.data.db.AppDatabase/12.json b/android/app/schemas/com.jossephus.chuchu.data.db.AppDatabase/12.json new file mode 100644 index 00000000..bd1ebeff --- /dev/null +++ b/android/app/schemas/com.jossephus.chuchu.data.db.AppDatabase/12.json @@ -0,0 +1,185 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "e1c59cbcdee1384ed9afa8331c0d766e", + "entities": [ + { + "tableName": "host_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `keyId` INTEGER, `keyPassphrase` TEXT NOT NULL, `transport` TEXT NOT NULL, `authMethod` TEXT NOT NULL, `requireAuthOnConnect` INTEGER NOT NULL DEFAULT 0, `postConnectCommand` TEXT, `multiplexer` TEXT DEFAULT NULL, `herdrNativeMode` INTEGER NOT NULL DEFAULT 1)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "keyPassphrase", + "columnName": "keyPassphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requireAuthOnConnect", + "columnName": "requireAuthOnConnect", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "postConnectCommand", + "columnName": "postConnectCommand", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "multiplexer", + "columnName": "multiplexer", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "herdrNativeMode", + "columnName": "herdrNativeMode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_host_profiles_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_host_profiles_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ssh_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `algorithm` TEXT NOT NULL, `privateKeyPem` TEXT NOT NULL, `publicKeyOpenSsh` TEXT NOT NULL, `createdAtEpochMs` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "algorithm", + "columnName": "algorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKeyPem", + "columnName": "privateKeyPem", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeyOpenSsh", + "columnName": "publicKeyOpenSsh", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtEpochMs", + "columnName": "createdAtEpochMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ssh_keys_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ssh_keys_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e1c59cbcdee1384ed9afa8331c0d766e')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/jossephus/chuchu/data/db/AppDatabase.kt b/android/app/src/main/java/com/jossephus/chuchu/data/db/AppDatabase.kt index 25e24063..261c0ab8 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/data/db/AppDatabase.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/data/db/AppDatabase.kt @@ -14,7 +14,7 @@ import com.jossephus.chuchu.model.SshKey @Database( entities = [HostProfile::class, SshKey::class], - version = 11, + version = 12, autoMigrations = [ AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7, spec = AppDatabase.Migration6To7::class), @@ -22,6 +22,7 @@ import com.jossephus.chuchu.model.SshKey AutoMigration(from = 8, to = 9), AutoMigration(from = 9, to = 10, spec = AppDatabase.Migration9To10::class), AutoMigration(from = 10, to = 11), + AutoMigration(from = 11, to = 12), ], ) @TypeConverters(Converters::class) diff --git a/android/app/src/main/java/com/jossephus/chuchu/model/HostProfile.kt b/android/app/src/main/java/com/jossephus/chuchu/model/HostProfile.kt index 73915c25..80c0b185 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/model/HostProfile.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/model/HostProfile.kt @@ -25,4 +25,6 @@ data class HostProfile( val postConnectCommand: String? = null, @ColumnInfo(defaultValue = "NULL") val multiplexer: MultiplexerType? = null, + @ColumnInfo(defaultValue = "1") + val herdrNativeMode: Boolean = true, ) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt index dfc5370c..1fde3366 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt @@ -21,6 +21,7 @@ data class TabSpec( val multiplexer: MultiplexerType? = null, val multiplexerSessionName: String? = null, val multiplexerCreateIfMissing: Boolean = true, + val herdrNativeMode: Boolean = true, ) { val sessionKey: String get() = @@ -52,7 +53,7 @@ data class TabSpec( transport != Transport.LocalShell val usesHerdrNativeMode: Boolean - get() = multiplexer == MultiplexerType.Herdr && usesRuntimeMultiplexer + get() = multiplexer == MultiplexerType.Herdr && usesRuntimeMultiplexer && herdrNativeMode companion object { fun fromHostProfile( @@ -78,6 +79,7 @@ data class TabSpec( host.transport != Transport.Mosh && host.transport != Transport.LocalShell }, + herdrNativeMode = host.herdrNativeMode, ) } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index d5a0bc62..d8aa0014 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -99,11 +99,15 @@ class TerminalSessionEngine( val multiplexer: MultiplexerType? = null, val multiplexerSessionName: String? = null, val multiplexerCreateIfMissing: Boolean = true, + val herdrNativeMode: Boolean = false, ) { fun multiplexerStartupCommand(): String? { val type = multiplexer ?: return null if (!type.runtimeSupported || transport == Transport.Mosh || transport == Transport.LocalShell) return null val sessionName = multiplexerSessionName?.takeIf { it.isNotBlank() } ?: return null + if (type == MultiplexerType.Herdr && herdrNativeMode) { + return HerdrMultiplexer.nativeModeLaunchCommand(sessionName) + } val runtime = MultiplexerRegistry.forType(type) ?: return null return runtime.launchCommand( sessionName = sessionName, @@ -206,6 +210,7 @@ class TerminalSessionEngine( multiplexer: MultiplexerType? = null, multiplexerSessionName: String? = null, multiplexerCreateIfMissing: Boolean = true, + herdrNativeMode: Boolean = false, ) { disconnectRequested = false stopHerdrStream() @@ -225,6 +230,7 @@ class TerminalSessionEngine( multiplexer = multiplexer, multiplexerSessionName = multiplexerSessionName, multiplexerCreateIfMissing = multiplexerCreateIfMissing, + herdrNativeMode = herdrNativeMode, ) lastConnectionParams = params scope.launch(dispatcher) { diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt index bbd0adb7..23768016 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt @@ -263,6 +263,7 @@ class TerminalSessionRepository private constructor(application: Application) { multiplexer = spec.multiplexer, multiplexerSessionName = spec.multiplexerSessionName, multiplexerCreateIfMissing = spec.multiplexerCreateIfMissing, + herdrNativeMode = spec.herdrNativeMode, ) updateHerdrCadence() updateHerdrPaneStreams() @@ -329,6 +330,7 @@ class TerminalSessionRepository private constructor(application: Application) { multiplexer = spec.multiplexer, multiplexerSessionName = spec.multiplexerSessionName, multiplexerCreateIfMissing = spec.multiplexerCreateIfMissing, + herdrNativeMode = spec.herdrNativeMode, ) } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt index be84c3d8..9929c9b9 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt @@ -255,6 +255,8 @@ fun AddServerScreen( MultiplexerSection( selected = form.multiplexer, onSelect = vm::updateMultiplexer, + herdrNativeMode = form.herdrNativeMode, + onHerdrNativeModeChanged = vm::updateHerdrNativeMode, ) SectionDivider() PostConnectActionSection( @@ -396,6 +398,8 @@ private enum class MultiplexerOption(val type: MultiplexerType?) { private fun MultiplexerSection( selected: MultiplexerType?, onSelect: (MultiplexerType?) -> Unit, + herdrNativeMode: Boolean, + onHerdrNativeModeChanged: (Boolean) -> Unit, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -415,6 +419,16 @@ private fun MultiplexerSection( onSelect = { onSelect(it.type) }, disabledOptions = setOf(MultiplexerOption.Zellij), ) + if (selected == MultiplexerType.Herdr) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + ChuText("native splits (beta)", style = typography.label) + ChuSwitch(checked = herdrNativeMode, onCheckedChange = onHerdrNativeModeChanged) + } + } } @Composable diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerViewModel.kt index 929dc037..698fc2af 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerViewModel.kt @@ -79,6 +79,7 @@ class AddServerViewModel( requireAuthOnConnect = profile.requireAuthOnConnect, postConnectCommand = profile.postConnectCommand.orEmpty(), multiplexer = profile.multiplexer, + herdrNativeMode = profile.herdrNativeMode, ) } } @@ -205,6 +206,10 @@ class AddServerViewModel( ) } + fun updateHerdrNativeMode(enabled: Boolean) { + _form.value = _form.value.copy(herdrNativeMode = enabled) + } + fun testConnection() { val current = _form.value if (current.host.isBlank()) return @@ -264,6 +269,7 @@ class AddServerViewModel( requireAuthOnConnect = current.requireAuthOnConnect, postConnectCommand = current.postConnectCommand.trim().ifBlank { null }, multiplexer = current.multiplexer.takeIf { current.transport != Transport.Mosh && it?.runtimeSupported == true }, + herdrNativeMode = current.herdrNativeMode, ) hostRepository.upsert(profile) onComplete() @@ -287,6 +293,7 @@ data class AddServerForm( val requireAuthOnConnect: Boolean = false, val postConnectCommand: String = "", val multiplexer: MultiplexerType? = null, + val herdrNativeMode: Boolean = true, ) fun AddServerForm.canSave(): Boolean { From 1cf0e1420997261d350a0c464f923a15b6322be2 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 10:36:19 +0300 Subject: [PATCH 13/42] fix: create herdr pane streams from reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pane streams were only created by the canvas onResize, but the canvas only mounts once a snapshot exists, which needs a running stream — a deadlock that left every pane stuck on "connecting…". Reconciliation now creates desired-but-missing streams with a bootstrap geometry; the canvas refines it once it mounts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../terminal/TerminalSessionRepository.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt index 23768016..5afb35b4 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt @@ -400,16 +400,27 @@ class TerminalSessionRepository private constructor(application: Application) { _tabs.value.forEach { tab -> if (tab.id == activeTab?.id && tab.spec.usesHerdrNativeMode) { val snapshot = (tab.engine.herdrState.value as? HerdrControlState.Active)?.snapshot - tab.engine.retainHerdrPaneStreams( + val desired = desiredHerdrPaneStreams( snapshot = snapshot, nativeModeActive = true, foreground = attachedClients > 0, - ), - ) + ) + tab.engine.retainHerdrPaneStreams(desired) if (snapshot != null && _herdrFocusedPaneId.value != snapshot.focusedPaneId) { setHerdrNativeFocus(snapshot.focusedPaneId) } + val existing = tab.engine.herdrPanes.value.keys + desired.filterNot { it in existing }.forEach { paneId -> + tab.engine.ensureHerdrPaneStream( + paneId = paneId, + cols = BOOTSTRAP_PANE_COLS, + rows = BOOTSTRAP_PANE_ROWS, + cellWidthPx = BOOTSTRAP_CELL_PX, + cellHeightPx = BOOTSTRAP_CELL_PX, + focused = paneId == snapshot?.focusedPaneId, + ) + } } else { tab.engine.retainHerdrPaneStreams(emptySet()) } @@ -592,6 +603,10 @@ class TerminalSessionRepository private constructor(application: Application) { } companion object { + private const val BOOTSTRAP_PANE_COLS = 80 + private const val BOOTSTRAP_PANE_ROWS = 24 + private const val BOOTSTRAP_CELL_PX = 10 + @Volatile private var instance: TerminalSessionRepository? = null fun getInstance(application: Application): TerminalSessionRepository { From 24febb018a28aeff3ae682e11f40284dc006a624 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 10:44:42 +0300 Subject: [PATCH 14/42] perf: hold control on every visible herdr pane, route input to focused Focus changes previously reconnected each pane (control<->observe swap = fresh SSH handshake), making switching slow. Panes are distinct herdr terminals that can each hold control concurrently, so every visible pane now opens and holds its own control stream; focus is pure input routing. Switching between panes is instant. Read-only fallback and takeover for panes another client controls are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../chuchu/service/terminal/TerminalSessionEngine.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index d8aa0014..222f016f 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -706,7 +706,7 @@ class TerminalSessionEngine( scope = scope, ) _herdrPaneHosts.value = _herdrPaneHosts.value + (paneId to host) - host.start(cols, rows, if (focused) HerdrStreamMode.Control else HerdrStreamMode.Observe) + host.start(cols, rows, HerdrStreamMode.Control) host.setViewport(cols, rows, cellWidthPx, cellHeightPx) if (focused) herdrFocusedPaneId = paneId } @@ -728,9 +728,6 @@ class TerminalSessionEngine( scope.launch(dispatcher) { val params = lastConnectionParams ?: return@launch if (!isHerdrSshSession(params)) return@launch - val previous = herdrFocusedPaneId - if (previous != paneId) _herdrPaneHosts.value[previous]?.setMode(HerdrStreamMode.Observe) - _herdrPaneHosts.value[paneId]?.setMode(HerdrStreamMode.Control) herdrFocusedPaneId = paneId } } From 94befcf68b7f6f95540f8a6b075668954602328f Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 10:52:23 +0300 Subject: [PATCH 15/42] feat: herdr switcher home on the connection tab The persistent connection tab now renders a native herdr home listing workspaces and agents with live status; tapping a workspace switches to it and tapping an agent jumps into its pane as native splits. Adds the agent snapshot model, a workspace-focus verb, a home-visible state that resets on connect, and a home chip in the tab strip. This gives workspace switching and makes the connection tab the herdr navigator. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrMultiplexer.kt | 3 + .../service/multiplexer/HerdrSnapshot.kt | 13 ++ .../service/terminal/TerminalSessionEngine.kt | 3 + .../ui/screens/Terminal/HerdrSwitcherHome.kt | 181 ++++++++++++++++++ .../ui/screens/Terminal/TerminalScreen.kt | 22 +++ .../ui/screens/Terminal/TerminalTabStrip.kt | 12 +- .../ui/screens/Terminal/TerminalViewModel.kt | 59 +++++- .../multiplexer/HerdrMultiplexerTest.kt | 8 + .../multiplexer/HerdrSnapshotParserTest.kt | 8 + 9 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt index 1817e18d..c2afa457 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -93,6 +93,9 @@ object HerdrMultiplexer : Multiplexer { fun focusTabCommand(tabId: String): String = pathPrelude + "herdr tab focus ${shellQuote(tabId)}" + fun focusWorkspaceCommand(workspaceId: String): String = + pathPrelude + "herdr workspace focus ${shellQuote(workspaceId)}" + fun focusPaneCommand(paneId: String): String = pathPrelude + "herdr agent focus ${shellQuote(paneId)}" diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt index 152d736d..32109185 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt @@ -20,6 +20,7 @@ data class HerdrSnapshot( val workspaces: List = emptyList(), val tabs: List = emptyList(), val panes: List = emptyList(), + val agents: List = emptyList(), val layouts: List = emptyList(), @SerialName("focused_workspace_id") val focusedWorkspaceId: String? = null, @SerialName("focused_tab_id") val focusedTabId: String? = null, @@ -142,6 +143,18 @@ data class HerdrPane( val focused: Boolean = false, ) +@Serializable +data class HerdrAgent( + @SerialName("pane_id") val paneId: String = "", + @SerialName("tab_id") val tabId: String = "", + @SerialName("workspace_id") val workspaceId: String = "", + val agent: String? = null, + @SerialName("agent_status") val agentStatus: HerdrAgentStatus = HerdrAgentStatus.Unknown, + val cwd: String? = null, + @SerialName("terminal_title_stripped") val terminalTitleStripped: String? = null, + val focused: Boolean = false, +) + @Serializable(with = HerdrAgentStatusSerializer::class) enum class HerdrAgentStatus { Idle, diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index 222f016f..eb8d4679 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -620,6 +620,9 @@ class TerminalSessionEngine( suspend fun herdrFocusTab(tabId: String): MultiplexerCommandResult = runHerdrControl { HerdrMultiplexer.focusTabCommand(tabId) } + suspend fun herdrFocusWorkspace(workspaceId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.focusWorkspaceCommand(workspaceId) } + suspend fun herdrFocusPane(paneId: String): MultiplexerCommandResult = runHerdrControl { HerdrMultiplexer.focusPaneCommand(paneId) } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt new file mode 100644 index 00000000..f5b24820 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -0,0 +1,181 @@ +package com.jossephus.chuchu.ui.screens.Terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.jossephus.chuchu.service.multiplexer.HerdrAgent +import com.jossephus.chuchu.service.multiplexer.HerdrAgentStatus +import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot +import com.jossephus.chuchu.service.multiplexer.HerdrWorkspace +import com.jossephus.chuchu.ui.components.ChuText +import com.jossephus.chuchu.ui.theme.ChuColorPalette +import com.jossephus.chuchu.ui.theme.ChuColors +import com.jossephus.chuchu.ui.theme.ChuTypography + +@Composable +fun HerdrSwitcherHome( + snapshot: HerdrSnapshot, + onEnterWorkspace: (String) -> Unit, + onEnterAgent: (agentPaneId: String, tabId: String) -> Unit, + colors: ChuColorPalette, + modifier: Modifier = Modifier, + sessionHint: String? = null, +) { + val typography = ChuTypography.current + val workspaces = snapshot.workspaces.sortedBy { it.number } + + Column(modifier = modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ChuText("herdr", style = typography.body, color = colors.textPrimary) + Spacer(modifier = Modifier.width(8.dp)) + ChuText( + text = sessionHint?.takeIf { it.isNotBlank() } ?: "session ${snapshot.version}", + style = typography.labelSmall, + color = colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (workspaces.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + ChuText("no herdr workspaces", style = typography.labelSmall, color = colors.textMuted) + } + return + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(workspaces, key = { it.workspaceId }) { workspace -> + HerdrSwitcherWorkspace( + workspace = workspace, + agents = snapshot.agents.filter { it.workspaceId == workspace.workspaceId }, + onEnterWorkspace = onEnterWorkspace, + onEnterAgent = onEnterAgent, + ) + } + } + } +} + +@Composable +private fun HerdrSwitcherWorkspace( + workspace: HerdrWorkspace, + agents: List, + onEnterWorkspace: (String) -> Unit, + onEnterAgent: (agentPaneId: String, tabId: String) -> Unit, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + val label = workspace.label?.takeIf { it.isNotBlank() } ?: "workspace ${workspace.number}" + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .background(if (workspace.focused) colors.surfaceVariant else Color.Transparent) + .border( + 1.dp, + if (workspace.focused) colors.accent.copy(alpha = 0.65f) else colors.border.copy(alpha = 0.35f), + ) + .clickable { onEnterWorkspace(workspace.workspaceId) } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChuText(label, style = typography.label, color = colors.textPrimary, modifier = Modifier.weight(1f)) + HerdrSwitcherStatus(workspace.agentStatus) + ChuText( + "${workspace.tabCount} tabs · ${workspace.paneCount} panes", + style = typography.labelSmall, + color = colors.textMuted, + ) + } + agents.forEach { agent -> + HerdrSwitcherAgent(agent, onEnterAgent) + } +} + +@Composable +private fun HerdrSwitcherAgent( + agent: HerdrAgent, + onEnterAgent: (agentPaneId: String, tabId: String) -> Unit, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + val name = agent.agent?.takeIf { it.isNotBlank() } ?: "shell" + val cwd = agent.cwd?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp, end = 12.dp, top = 1.dp, bottom = 1.dp) + .background(if (agent.focused) colors.surfaceVariant else Color.Transparent) + .border( + 1.dp, + if (agent.focused) colors.accent.copy(alpha = 0.5f) else colors.border.copy(alpha = 0.2f), + ) + .clickable { onEnterAgent(agent.paneId, agent.tabId) } + .padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + ChuText(name, style = typography.body, color = colors.textSecondary) + Spacer(modifier = Modifier.width(8.dp)) + HerdrSwitcherStatus(agent.agentStatus) + } + agent.terminalTitleStripped?.takeIf { it.isNotBlank() }?.let { title -> + ChuText( + title, + style = typography.labelSmall, + color = colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + cwd?.let { + ChuText( + it, + style = typography.labelSmall, + color = colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun HerdrSwitcherStatus(status: HerdrAgentStatus) { + val colors = ChuColors.current + val typography = ChuTypography.current + val color = herdrAgentStatusColor(status, colors) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box(modifier = Modifier.size(6.dp).background(color)) + ChuText(status.name.lowercase(), style = typography.labelSmall, color = color) + } +} diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index 90435b45..345d4ca0 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -694,6 +694,8 @@ fun TerminalScreen( herdrState = herdrUiState.control, onHerdrFocusTab = vm::onHerdrFocusTab, onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrHome = vm::onShowHerdrHome, + herdrNativeMode = herdrNativeState.enabled, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), ) Box( @@ -783,6 +785,8 @@ fun TerminalScreen( herdrState = herdrUiState.control, onHerdrFocusTab = vm::onHerdrFocusTab, onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrHome = vm::onShowHerdrHome, + herdrNativeMode = herdrNativeState.enabled, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), ) Box( @@ -1107,6 +1111,8 @@ fun TerminalScreen( herdrState = herdrUiState.control, onHerdrFocusTab = vm::onHerdrFocusTab, onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrHome = vm::onShowHerdrHome, + herdrNativeMode = herdrNativeState.enabled, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), ) } @@ -1273,7 +1279,21 @@ fun TerminalScreen( } } else { Box(modifier = Modifier.weight(1f)) { + val herdrSnapshot = herdrNativeState.snapshot when { + herdrNativeState.enabled && + herdrNativeState.homeVisible && + herdrSnapshot != null -> { + HerdrSwitcherHome( + snapshot = herdrSnapshot, + onEnterWorkspace = vm::onEnterHerdrWorkspace, + onEnterAgent = vm::onEnterHerdrAgent, + colors = colors, + sessionHint = activeTab?.let(::terminalTabDisplayLabel), + modifier = Modifier.fillMaxSize(), + ) + } + herdrNativeState.enabled && herdrNativeState.layout?.panes?.isNotEmpty() == true -> { HerdrSplitLayout( @@ -1837,6 +1857,8 @@ fun TerminalScreen( herdrState = herdrUiState.control, onHerdrFocusTab = vm::onHerdrFocusTab, onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrHome = vm::onShowHerdrHome, + herdrNativeMode = herdrNativeState.enabled, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), ) Box( diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt index 9ecee9a0..0f61feca 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt @@ -72,6 +72,8 @@ fun TerminalTabStrip( herdrState: HerdrControlState = HerdrControlState.Inactive, onHerdrFocusTab: (String) -> Unit = {}, onHerdrCreateTab: (String) -> Unit = {}, + onHerdrHome: () -> Unit = {}, + herdrNativeMode: Boolean = false, hostChipLabel: String? = null, ) { val colors = ChuColors.current @@ -109,16 +111,18 @@ fun TerminalTabStrip( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - val hostLabel = hostChipLabel - ?: tabs.firstOrNull { it.id == activeTabId }?.let(::terminalTabDisplayLabel) - ?: "terminal" + val hostLabel = + if (herdrNativeMode) "⌂ herdr" + else hostChipLabel + ?: tabs.firstOrNull { it.id == activeTabId }?.let(::terminalTabDisplayLabel) + ?: "terminal" Box( modifier = Modifier .semantics { contentDescription = hostLabel } .defaultMinSize(minHeight = 32.dp) .widthIn(min = 44.dp, max = 160.dp) .clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp)) - .clickable(onClick = onOpenManager) + .clickable { if (herdrNativeMode) onHerdrHome() else onOpenManager() } .padding(horizontal = 8.dp, vertical = 3.dp), contentAlignment = Alignment.Center, ) { diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index 7b51d213..0893372a 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -13,6 +13,7 @@ import com.jossephus.chuchu.model.HostProfile import com.jossephus.chuchu.model.MultiplexerType import com.jossephus.chuchu.model.Transport import com.jossephus.chuchu.service.multiplexer.HerdrControlState +import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot import com.jossephus.chuchu.service.multiplexer.HerdrTabLayout import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry import com.jossephus.chuchu.service.multiplexer.MultiplexerCommandResult @@ -48,6 +49,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -77,6 +79,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private var multiplexerActionGeneration = 0L private var multiplexerSessionListGeneration = 0L private val _herdrActionError = MutableStateFlow(null) + private val _herdrHomeVisible = MutableStateFlow(true) + val herdrHomeVisible: StateFlow = _herdrHomeVisible.asStateFlow() private val _tailscaleActive = MutableStateFlow(tailscaleStatusChecker.isActive()) val tailscaleActive: StateFlow = _tailscaleActive.asStateFlow() @@ -108,13 +112,16 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application tab.engine.herdrState, tab.engine.herdrPaneStates, sessionRepository.herdrFocusedPaneId, - ) { control, panes, focusedPaneId -> + herdrHomeVisible, + ) { control, panes, focusedPaneId, homeVisible -> val snapshot = (control as? HerdrControlState.Active)?.snapshot HerdrNativeUiState( enabled = true, + snapshot = snapshot, layout = snapshot?.layouts?.firstOrNull { it.tabId == snapshot.focusedTabId }, panes = panes, focusedPaneId = focusedPaneId, + homeVisible = homeVisible, ) } } else { @@ -129,6 +136,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application init { sessionRepository.attachClient() + viewModelScope.launch { + activeTabId.collect { id -> + val tab = sessionRepository.tabs.value.firstOrNull { it.id == id } + if (tab?.spec?.usesHerdrNativeMode == true) _herdrHomeVisible.value = true + } + } } private val _connectionTabByTab = MutableStateFlow>(emptyMap()) @@ -458,10 +471,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application } fun onHerdrFocusTab(tabId: String) { + showHerdrSplitsIfNative() runHerdrCommand { it.herdrFocusTab(tabId) } } fun onHerdrFocusPane(paneId: String) { + showHerdrSplitsIfNative() runHerdrCommand { it.herdrFocusPane(paneId) } } @@ -494,6 +509,29 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application runHerdrCommand { it.herdrCreateTab(workspaceId) } } + fun onEnterHerdrWorkspace(workspaceId: String) { + _herdrHomeVisible.value = false + runHerdrCommand { it.herdrFocusWorkspace(workspaceId) } + } + + fun onEnterHerdrAgent(paneId: String, tabId: String) { + val engine = sessionRepository.activeTab.value?.engine ?: return + _herdrHomeVisible.value = false + viewModelScope.launch { + val tabResult = engine.herdrFocusTab(tabId) + if (tabResult.exitCode != 0) { + updateHerdrActionError(tabResult) + return@launch + } + sessionRepository.setHerdrNativeFocus(paneId) + updateHerdrActionError(engine.herdrFocusPane(paneId)) + } + } + + fun onShowHerdrHome() { + _herdrHomeVisible.value = true + } + fun onHerdrCloseTab(tabId: String) { runHerdrCommand { it.herdrCloseTab(tabId) } } @@ -508,13 +546,22 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application ) { val engine = sessionRepository.activeTab.value?.engine ?: return viewModelScope.launch { - val result = command(engine) - _herdrActionError.value = - if (result.exitCode == 0) null - else result.stderr.ifBlank { result.stdout.ifBlank { "Herdr command failed" } } + updateHerdrActionError(command(engine)) } } + private fun showHerdrSplitsIfNative() { + if (sessionRepository.activeTab.value?.spec?.usesHerdrNativeMode == true) { + _herdrHomeVisible.value = false + } + } + + private fun updateHerdrActionError(result: MultiplexerCommandResult) { + _herdrActionError.value = + if (result.exitCode == 0) null + else result.stderr.ifBlank { result.stdout.ifBlank { "Herdr command failed" } } + } + fun closeTab(id: String) { _connectionTabByTab.value = _connectionTabByTab.value - id _fileBrowserStateByTab.value = _fileBrowserStateByTab.value - id @@ -868,7 +915,9 @@ data class HerdrUiState( data class HerdrNativeUiState( val enabled: Boolean = false, + val snapshot: HerdrSnapshot? = null, val layout: HerdrTabLayout? = null, val panes: Map = emptyMap(), val focusedPaneId: String? = null, + val homeVisible: Boolean = true, ) diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt index fa30cbc0..a25b32c7 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt @@ -139,4 +139,12 @@ class HerdrMultiplexerTest { ), ) } + + @Test + fun focusWorkspaceCommandQuotesWorkspaceId() { + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; herdr workspace focus 'workspace'\\''; no'", + HerdrMultiplexer.focusWorkspaceCommand("workspace'; no"), + ) + } } diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt index 2728e116..3118502b 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt @@ -16,8 +16,16 @@ class HerdrSnapshotParserTest { assertEquals(1, snapshot.workspaces.size) assertEquals(2, snapshot.tabs.size) assertEquals(2, snapshot.panes.size) + assertEquals(2, snapshot.agents.size) assertEquals(emptyList(), snapshot.layouts) + val agent = snapshot.agents.first { it.paneId == "wJ:p3D" } + assertEquals("pi", agent.agent) + assertEquals(HerdrAgentStatus.Idle, agent.agentStatus) + assertEquals("wJ:tR", agent.tabId) + assertEquals("wJ", agent.workspaceId) + assertEquals("/home/salem/coding/ERBareeq", agent.cwd) + val pane = snapshot.panes.first { it.paneId == "wJ:p5Y" } assertEquals("pi", pane.agent) assertEquals(HerdrAgentStatus.Working, pane.agentStatus) From d1bdda461df599ad33ac3347a7710f56dfa0c668 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 10:59:53 +0300 Subject: [PATCH 16/42] fix: apply current theme to newly created herdr pane VTs Pane VTs are created after the app's color scheme is set, so they fell back to ghostty's dark default while earlier panes rendered light. Apply the engine's pending color scheme and default colors to each pane host on creation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../jossephus/chuchu/service/terminal/TerminalSessionEngine.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index eb8d4679..58e90b93 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -711,6 +711,8 @@ class TerminalSessionEngine( _herdrPaneHosts.value = _herdrPaneHosts.value + (paneId to host) host.start(cols, rows, HerdrStreamMode.Control) host.setViewport(cols, rows, cellWidthPx, cellHeightPx) + pendingColorScheme?.let { host.setColorScheme(it == 1) } + pendingDefaultColors?.let { host.setDefaultColors(it.fg, it.bg, it.cursor, it.palette) } if (focused) herdrFocusedPaneId = paneId } } From 2d071246703f3516e988fda99c12fe8dd5f8f5eb Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 11:10:57 +0300 Subject: [PATCH 17/42] feat: add multi-exec-channel support to the native SSH bridge Adds an additive channel table (up to 16 exec channels) on the native SSH session plus JNI to open, exchange (read/write), query EOF, and close individual exec channels, so one connection can carry many simultaneous streams. Existing single-channel shell/exec/pty/SFTP paths are unchanged (writeChannel/readChannel now take an explicit channel; legacy callers pass the single channel). Teardown frees all table channels. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../chuchu/service/ssh/NativeSshBridge.kt | 8 + zig-src/src/bridge/chuchu_ssh.zig | 197 ++++++++++++++++-- 2 files changed, 193 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/ssh/NativeSshBridge.kt b/android/app/src/main/java/com/jossephus/chuchu/service/ssh/NativeSshBridge.kt index 7e6d02d6..e451a8bb 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/ssh/NativeSshBridge.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/ssh/NativeSshBridge.kt @@ -51,6 +51,8 @@ class NativeSshBridge { external fun nativeOpenExec(handle: Long, command: String): Boolean + external fun nativeOpenExecChannel(handle: Long, command: String): Int + external fun nativeOpenExecPty( handle: Long, command: String, @@ -63,6 +65,10 @@ class NativeSshBridge { external fun nativeChannelEof(handle: Long): Boolean + external fun nativeChannelEofById(handle: Long, channelId: Int): Boolean + + external fun nativeCloseChannel(handle: Long, channelId: Int) + external fun nativeResize( handle: Long, cols: Int, @@ -73,6 +79,8 @@ class NativeSshBridge { external fun nativeIpcExchange(handle: Long, request: ByteArray): ByteArray? + external fun nativeChannelExchange(handle: Long, channelId: Int, request: ByteArray): ByteArray + external fun nativeSftpInit(handle: Long): Boolean external fun nativeSftpListDirectory(handle: Long, path: String): Array? diff --git a/zig-src/src/bridge/chuchu_ssh.zig b/zig-src/src/bridge/chuchu_ssh.zig index 4730d104..dd62c51c 100644 --- a/zig-src/src/bridge/chuchu_ssh.zig +++ b/zig-src/src/bridge/chuchu_ssh.zig @@ -38,11 +38,14 @@ const io_wait_timeout_ms = 120; // SFTP servers may make progress between short 120ms socket waits; use a // longer idle cap before failing directory listing/realpath as truly stalled. const sftp_idle_limit_ms: i64 = 15_000; +const MAX_EXEC_CHANNELS = 16; const NativeSshSession = struct { socket_fd: c_int = -1, session: ?*c.LIBSSH2_SESSION = null, channel: ?*c.LIBSSH2_CHANNEL = null, + exec_channels: [MAX_EXEC_CHANNELS]?*c.LIBSSH2_CHANNEL = [_]?*c.LIBSSH2_CHANNEL{null} ** MAX_EXEC_CHANNELS, + exec_empty_reads: [MAX_EXEC_CHANNELS]u32 = [_]u32{0} ** MAX_EXEC_CHANNELS, sftp: ?*c.LIBSSH2_SFTP = null, upload_handle: ?*c.LIBSSH2_SFTP_HANDLE = null, username: ?[]u8 = null, @@ -207,6 +210,14 @@ fn destroyNativeSshSession(session: *NativeSshSession) void { _ = c.libssh2_channel_close(channel); _ = c.libssh2_channel_free(channel); } + for (&session.exec_channels, 0..) |*channel_slot, index| { + if (channel_slot.*) |channel| { + _ = c.libssh2_channel_close(channel); + _ = c.libssh2_channel_free(channel); + channel_slot.* = null; + session.exec_empty_reads[index] = 0; + } + } if (session.session) |ssh_session| { _ = c.libssh2_session_disconnect_ex(ssh_session, c.SSH_DISCONNECT_BY_APPLICATION, "bye", "en"); _ = c.libssh2_session_free(ssh_session); @@ -433,11 +444,7 @@ fn appendErrorFrame(response: *std.ArrayList(u8), session: *NativeSshSession, fa ipc.appendMessage(allocator, response, .Error, message) catch {}; } -fn writeChannel(session: *NativeSshSession, bytes: []const u8) c.jint { - const channel = session.channel orelse { - setError(session, "Shell not open", .{}); - return -1; - }; +fn writeChannel(session: *NativeSshSession, channel: *c.LIBSSH2_CHANNEL, bytes: []const u8) c.jint { if (bytes.len == 0) return 0; var total_written: usize = 0; var stalled_loops: u32 = 0; @@ -464,8 +471,7 @@ fn writeChannel(session: *NativeSshSession, bytes: []const u8) c.jint { return @intCast(total_written); } -fn readChannel(alloc: std.mem.Allocator, session: *NativeSshSession, max_bytes: usize) ?[]u8 { - const channel = session.channel orelse return null; +fn readChannel(alloc: std.mem.Allocator, session: *NativeSshSession, channel: *c.LIBSSH2_CHANNEL, max_bytes: usize, empty_reads: *u32) ?[]u8 { const cap = @max(max_bytes, 1); const buf = alloc.alloc(u8, cap) catch return null; defer alloc.free(buf); @@ -478,7 +484,7 @@ fn readChannel(alloc: std.mem.Allocator, session: *NativeSshSession, max_bytes: if (total_read >= buf.len) break; const rc = c.libssh2_channel_read_ex(channel, stream_id, @ptrCast(buf.ptr + total_read), @intCast(buf.len - total_read)); if (rc == c.LIBSSH2_ERROR_EAGAIN) { - session.empty_reads +%= 1; + empty_reads.* +%= 1; break; } if (rc == 0) { @@ -489,7 +495,7 @@ fn readChannel(alloc: std.mem.Allocator, session: *NativeSshSession, max_bytes: return null; } total_read += @intCast(rc); - session.empty_reads = 0; + empty_reads.* = 0; } } if (total_read == 0) { @@ -516,7 +522,10 @@ export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeCreateSess _ = thiz; _ = c.libssh2_init(0); const session = allocator.create(NativeSshSession) catch return 0; - session.* = .{}; + session.* = .{ + .exec_channels = [_]?*c.LIBSSH2_CHANNEL{null} ** MAX_EXEC_CHANNELS, + .exec_empty_reads = [_]u32{0} ** MAX_EXEC_CHANNELS, + }; return handleFromSession(session); } @@ -1011,6 +1020,69 @@ export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeOpenExec(e return c.JNI_TRUE; } +export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeOpenExecChannel(env: *c.JNIEnv, thiz: c.jobject, handle: c.jlong, command: c.jstring) callconv(.c) c.jint { + _ = thiz; + const session = sessionFromHandle(handle) orelse return -1; + const ssh_session = session.session orelse { + setError(session, "Not connected", .{}); + return -1; + }; + const channel_index = for (session.exec_channels, 0..) |channel, index| { + if (channel == null) break index; + } else { + setError(session, "No exec channel slots available", .{}); + return -1; + }; + const command_slice = jniDupString(env, command) orelse { + setError(session, "Missing exec command", .{}); + return -1; + }; + defer allocator.free(command_slice); + + var channel: ?*c.LIBSSH2_CHANNEL = null; + var open_attempts: u32 = 0; + while (channel == null) { + channel = c.libssh2_channel_open_ex(ssh_session, "session", 7, c.LIBSSH2_CHANNEL_WINDOW_DEFAULT, c.LIBSSH2_CHANNEL_PACKET_DEFAULT, null, 0); + if (channel != null) break; + const open_rc = c.libssh2_session_last_errno(ssh_session); + if (open_rc != c.LIBSSH2_ERROR_EAGAIN) { + setLibssh2Error(session, "Channel open failed", open_rc); + return -1; + } + open_attempts +%= 1; + if (open_attempts > 64 or !waitSocket(session, setup_wait_timeout_ms)) { + setError(session, "Channel open timed out", .{}); + return -1; + } + } + c.libssh2_channel_set_blocking(channel.?, 0); + + var startup_attempts: u32 = 0; + while (true) { + const startup_rc = c.libssh2_channel_process_startup(channel.?, "exec", 4, command_slice.ptr, @intCast(command_slice.len)); + if (startup_rc == 0) break; + if (startup_rc != c.LIBSSH2_ERROR_EAGAIN) { + setLibssh2Error(session, "Exec start failed", startup_rc); + _ = c.libssh2_channel_close(channel.?); + _ = c.libssh2_channel_free(channel.?); + return -1; + } + startup_attempts +%= 1; + if (startup_attempts > 64 or !waitSocket(session, setup_wait_timeout_ms)) { + setError(session, "Exec start timed out", .{}); + _ = c.libssh2_channel_close(channel.?); + _ = c.libssh2_channel_free(channel.?); + return -1; + } + } + session.exec_channels[channel_index] = channel; + session.exec_empty_reads[channel_index] = 0; + setSocketNonBlocking(session.socket_fd); + c.libssh2_session_set_blocking(ssh_session, 0); + c.libssh2_channel_set_blocking(channel.?, 0); + return @intCast(channel_index); +} + export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeOpenExecPty(env: *c.JNIEnv, thiz: c.jobject, handle: c.jlong, command: c.jstring, cols: c.jint, rows: c.jint, width_px: c.jint, height_px: c.jint, term: c.jstring) callconv(.c) c.jboolean { _ = thiz; const session = sessionFromHandle(handle) orelse return c.JNI_FALSE; @@ -1139,7 +1211,12 @@ export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeIpcExchang switch (frame.header.tag) { .Write => { - const written = writeChannel(session, frame.payload); + const channel = session.channel orelse { + setError(session, "Shell not open", .{}); + appendErrorFrame(&response, session, "Write failed"); + return jniNewByteArrayOrNull(env, response.items); + }; + const written = writeChannel(session, channel, frame.payload); if (written < 0) { appendErrorFrame(&response, session, "Write failed"); return jniNewByteArrayOrNull(env, response.items); @@ -1154,7 +1231,11 @@ export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeIpcExchang return jniNewByteArrayOrNull(env, response.items); } const max_bytes = std.mem.bytesToValue(u32, frame.payload[0..@sizeOf(u32)]); - const bytes = readChannel(allocator, session, @intCast(@max(max_bytes, 1))) orelse { + const channel = session.channel orelse { + appendErrorFrame(&response, session, "Read failed"); + return jniNewByteArrayOrNull(env, response.items); + }; + const bytes = readChannel(allocator, session, channel, @intCast(@max(max_bytes, 1)), &session.empty_reads) orelse { appendErrorFrame(&response, session, "Read failed"); return jniNewByteArrayOrNull(env, response.items); }; @@ -1170,6 +1251,90 @@ export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeIpcExchang return jniNewByteArrayOrNull(env, response.items); } +export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeChannelExchange(env: *c.JNIEnv, thiz: c.jobject, handle: c.jlong, channel_id: c.jint, request: c.jbyteArray) callconv(.c) c.jbyteArray { + _ = thiz; + const session = sessionFromHandle(handle) orelse return null; + + var response: std.ArrayList(u8) = .empty; + defer response.deinit(allocator); + + if (channel_id < 0 or channel_id >= @as(c.jint, MAX_EXEC_CHANNELS)) { + setError(session, "Invalid exec channel ID", .{}); + appendErrorFrame(&response, session, "Invalid exec channel ID"); + return jniNewByteArrayOrNull(env, response.items); + } + const channel_index: usize = @intCast(channel_id); + const channel = session.exec_channels[channel_index] orelse { + setError(session, "Exec channel not open", .{}); + appendErrorFrame(&response, session, "Exec channel not open"); + return jniNewByteArrayOrNull(env, response.items); + }; + const req_bytes = readJByteArray(env, request) orelse return null; + defer if (req_bytes.len > 0) allocator.free(req_bytes); + + const frame = ipc.parse(req_bytes) catch { + setError(session, "Invalid IPC frame", .{}); + appendErrorFrame(&response, session, "Invalid IPC frame"); + return jniNewByteArrayOrNull(env, response.items); + }; + + switch (frame.header.tag) { + .Write => { + const written = writeChannel(session, channel, frame.payload); + if (written < 0) { + appendErrorFrame(&response, session, "Write failed"); + return jniNewByteArrayOrNull(env, response.items); + } + const written_u32: u32 = @intCast(written); + ipc.appendMessage(allocator, &response, .Ack, std.mem.asBytes(&written_u32)) catch {}; + }, + .Read => { + if (frame.payload.len != @sizeOf(u32)) { + setError(session, "Invalid read request payload", .{}); + appendErrorFrame(&response, session, "Invalid read request payload"); + return jniNewByteArrayOrNull(env, response.items); + } + const max_bytes = std.mem.bytesToValue(u32, frame.payload[0..@sizeOf(u32)]); + const bytes = readChannel(allocator, session, channel, @intCast(@max(max_bytes, 1)), &session.exec_empty_reads[channel_index]) orelse { + appendErrorFrame(&response, session, "Read failed"); + return jniNewByteArrayOrNull(env, response.items); + }; + defer allocator.free(bytes); + ipc.appendMessage(allocator, &response, .Data, bytes) catch {}; + }, + else => { + setError(session, "Unsupported IPC tag {}", .{@intFromEnum(frame.header.tag)}); + appendErrorFrame(&response, session, "Unsupported IPC tag"); + }, + } + + return jniNewByteArrayOrNull(env, response.items); +} + +export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeChannelEofById(env: *c.JNIEnv, thiz: c.jobject, handle: c.jlong, channel_id: c.jint) callconv(.c) c.jboolean { + _ = env; + _ = thiz; + const session = sessionFromHandle(handle) orelse return c.JNI_TRUE; + if (channel_id < 0 or channel_id >= @as(c.jint, MAX_EXEC_CHANNELS)) return c.JNI_TRUE; + const channel_index: usize = @intCast(channel_id); + const channel = session.exec_channels[channel_index] orelse return c.JNI_TRUE; + return if (c.libssh2_channel_eof(channel) == 1) c.JNI_TRUE else c.JNI_FALSE; +} + +export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeCloseChannel(env: *c.JNIEnv, thiz: c.jobject, handle: c.jlong, channel_id: c.jint) callconv(.c) void { + _ = env; + _ = thiz; + const session = sessionFromHandle(handle) orelse return; + if (channel_id < 0 or channel_id >= @as(c.jint, MAX_EXEC_CHANNELS)) return; + const channel_index: usize = @intCast(channel_id); + if (session.exec_channels[channel_index]) |channel| { + _ = c.libssh2_channel_close(channel); + _ = c.libssh2_channel_free(channel); + session.exec_channels[channel_index] = null; + session.exec_empty_reads[channel_index] = 0; + } +} + export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeClose(env: *c.JNIEnv, thiz: c.jobject, handle: c.jlong) callconv(.c) void { _ = env; _ = thiz; @@ -1190,6 +1355,14 @@ export fn Java_com_jossephus_chuchu_service_ssh_NativeSshBridge_nativeClose(env: _ = c.libssh2_channel_free(channel); session.channel = null; } + for (&session.exec_channels, 0..) |*channel_slot, index| { + if (channel_slot.*) |channel| { + _ = c.libssh2_channel_close(channel); + _ = c.libssh2_channel_free(channel); + channel_slot.* = null; + session.exec_empty_reads[index] = 0; + } + } if (session.session) |ssh_session| { _ = c.libssh2_session_disconnect_ex(ssh_session, c.SSH_DISCONNECT_BY_APPLICATION, "bye", "en"); _ = c.libssh2_session_free(ssh_session); From 893f343be8416e80091be57ddcfeebc879acc967 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 11:20:32 +0300 Subject: [PATCH 18/42] perf: stream herdr panes and verbs over one shared SSH connection Introduces SharedSshConnection: one authenticated SSH session per active herdr session, with pane streams and control verbs multiplexed as separate exec channels over it instead of a fresh SSH handshake each. All channel ops are serialized on a dedicated per-connection dispatcher (libssh2 is not thread-safe), and connection setup is guarded by a mutex so concurrent pane starts share one connection. Pane retries reopen a channel rather than reconnecting. Collapses per-pane and per-verb handshakes (~0.8s each over a high-latency link) into cheap channel opens. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../chuchu/service/ssh/NativeSshService.kt | 5 +- .../chuchu/service/ssh/SharedSshConnection.kt | 202 ++++++++++++++++++ .../chuchu/service/terminal/HerdrPaneHost.kt | 58 ++--- .../service/terminal/TerminalSessionEngine.kt | 52 +++-- .../service/ssh/SharedSshConnectionTest.kt | 32 +++ 5 files changed, 306 insertions(+), 43 deletions(-) create mode 100644 android/app/src/main/java/com/jossephus/chuchu/service/ssh/SharedSshConnection.kt create mode 100644 android/app/src/test/java/com/jossephus/chuchu/service/ssh/SharedSshConnectionTest.kt diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/ssh/NativeSshService.kt b/android/app/src/main/java/com/jossephus/chuchu/service/ssh/NativeSshService.kt index 0ebe127b..a77a75e5 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/ssh/NativeSshService.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/ssh/NativeSshService.kt @@ -21,7 +21,7 @@ class NativeSshService( return nativeError } - private object Ipc { + internal object Ipc { private const val VERSION: Byte = 1 private const val TAG_WRITE: Byte = 1 private const val TAG_READ: Byte = 2 @@ -97,6 +97,9 @@ class NativeSshService( private var handle: Long = 0L + internal val sessionHandle: Long + get() = handle + fun isAvailable(): Boolean = bridge.isLoaded() fun connect( diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/ssh/SharedSshConnection.kt b/android/app/src/main/java/com/jossephus/chuchu/service/ssh/SharedSshConnection.kt new file mode 100644 index 00000000..7218abfa --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/ssh/SharedSshConnection.kt @@ -0,0 +1,202 @@ +package com.jossephus.chuchu.service.ssh + +import com.jossephus.chuchu.model.AuthMethod +import com.jossephus.chuchu.service.multiplexer.MultiplexerCommandResult +import java.io.Closeable +import java.util.concurrent.Executors +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +class SharedSshConnection( + private val host: String, + private val port: Int, + private val username: String, + private val authMethod: AuthMethod, + private val password: String, + private val publicKeyOpenSsh: String, + private val privateKeyPem: String, + private val keyPassphrase: String, + private val hostKeyPolicy: HostKeyPolicy, + private val bridge: NativeSshBridge = NativeSshBridge(), +) : Closeable { + private val connDispatcher: ExecutorCoroutineDispatcher = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "ssh-conn").apply { isDaemon = true } + } + .asCoroutineDispatcher() + private val service = NativeSshService(bridge, hostKeyPolicy) + + @Volatile + var connected: Boolean = false + private set + + @Volatile private var closed = false + + suspend fun connect() { + withContext(connDispatcher) { + check(!closed) { "Shared SSH connection is closed" } + if (connected) return@withContext + try { + service.connect( + host = host, + port = port, + username = username, + authMethod = authMethod, + password = password, + publicKeyOpenSsh = publicKeyOpenSsh, + privateKeyPem = privateKeyPem, + keyPassphrase = keyPassphrase, + ) + connected = true + } catch (error: Exception) { + service.close() + throw error + } + } + } + + suspend fun openExecChannel(command: String): Int = + withContext(connDispatcher) { + val handle = requireHandle() + val channelId = bridge.nativeOpenExecChannel(handle, command) + if (channelId < 0) { + throw IllegalStateException( + bridge.nativeGetLastError(handle) ?: "Remote server did not open an exec channel", + ) + } + channelId + } + + suspend fun writeChannel(id: Int, data: ByteArray) { + if (data.isEmpty()) return + withContext(connDispatcher) { + val handle = requireHandle() + var offset = 0 + var stalledWrites = 0 + while (offset < data.size) { + val chunk = if (offset == 0) data else data.copyOfRange(offset, data.size) + val decoded = NativeSshService.Ipc.decode( + bridge.nativeChannelExchange(handle, id, NativeSshService.Ipc.encodeWrite(chunk)), + ) + val written = + when (decoded.tag) { + NativeSshService.Ipc.tagAck -> + NativeSshService.Ipc.parseAckWritten(decoded.payload) + NativeSshService.Ipc.tagError -> + throw IllegalStateException(NativeSshService.Ipc.parseError(decoded.payload)) + else -> + throw IllegalStateException( + "Unexpected IPC write response tag: ${decoded.tag}", + ) + } + if (written == 0) { + stalledWrites += 1 + if (stalledWrites > 64) throw IllegalStateException("Native SSH write stalled") + Thread.sleep(4) + continue + } + val remaining = data.size - offset + if (written < 0 || written > remaining) { + throw IllegalStateException( + "Invalid native SSH ACK size: $written (remaining=$remaining)", + ) + } + stalledWrites = 0 + offset += written + } + } + } + + suspend fun readChannel(id: Int, maxBytes: Int = 8192): ByteArray? = + withContext(connDispatcher) { + val handle = requireHandle() + val decoded = NativeSshService.Ipc.decode( + bridge.nativeChannelExchange(handle, id, NativeSshService.Ipc.encodeRead(maxBytes)), + ) + when (decoded.tag) { + NativeSshService.Ipc.tagData -> decoded.payload + NativeSshService.Ipc.tagError -> + throw IllegalStateException(NativeSshService.Ipc.parseError(decoded.payload)) + else -> throw IllegalStateException("Unexpected IPC read response tag: ${decoded.tag}") + } + } + + suspend fun channelEof(id: Int): Boolean = + withContext(connDispatcher) { + if (!connected || closed) true else bridge.nativeChannelEofById(requireHandle(), id) + } + + suspend fun closeChannel(id: Int) { + withContext(connDispatcher) { + if (connected && !closed) bridge.nativeCloseChannel(requireHandle(), id) + } + } + + suspend fun runCommand( + command: String, + timeoutMs: Long = 20_000, + ): MultiplexerCommandResult { + val channelId = openExecChannel(withExitEnvelope(command)) + return try { + readExecOutput(channelId, timeoutMs) + } finally { + closeChannel(channelId) + } + } + + override fun close() { + if (closed) return + runBlocking { + withContext(connDispatcher) { + if (closed) return@withContext + closed = true + connected = false + service.close() + } + } + connDispatcher.close() + } + + private fun requireHandle(): Long = + service.sessionHandle.takeIf { connected && !closed } + ?: throw IllegalStateException("Shared SSH connection is not connected") + + private suspend fun readExecOutput( + channelId: Int, + timeoutMs: Long, + ): MultiplexerCommandResult { + val output = StringBuilder() + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val chunk = readChannel(channelId, 4096) + if (chunk != null && chunk.isNotEmpty()) { + output.append(String(chunk, Charsets.UTF_8)) + } else if (channelEof(channelId)) { + return parseCommandEnvelope(output.toString()) + } else { + delay(25) + } + } + return MultiplexerCommandResult(124, output.toString(), "Command timed out") + } + + internal companion object { + fun withExitEnvelope(command: String): String = + "$command; printf '\nCHUCHU_EXIT:%s\n' \"\$?\"" + + fun parseCommandEnvelope(output: String): MultiplexerCommandResult { + val marker = Regex("(?:^|\\n)CHUCHU_EXIT:(\\d+)\\s*$").find(output) + ?: return MultiplexerCommandResult( + exitCode = 125, + stdout = output, + stderr = "Missing command exit marker", + ) + val exitCode = marker.groupValues.getOrNull(1)?.toIntOrNull() ?: 125 + val cleanOutput = output.substring(0, marker.range.first).trimEnd() + return MultiplexerCommandResult(exitCode = exitCode, stdout = cleanOutput, stderr = "") + } + } +} diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt index dd9c302d..913ac073 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt @@ -12,13 +12,14 @@ import com.jossephus.chuchu.service.multiplexer.herdrResizeJson import com.jossephus.chuchu.service.multiplexer.herdrScrollCommand import com.jossephus.chuchu.service.multiplexer.herdrScrollJson import com.jossephus.chuchu.service.multiplexer.parseHerdrStreamMessage -import com.jossephus.chuchu.service.ssh.NativeSshService +import com.jossephus.chuchu.service.ssh.SharedSshConnection import java.util.concurrent.Executors import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext @@ -48,7 +49,7 @@ data class HerdrPaneState( class HerdrPaneHost( val paneId: String, - private val sshFactory: () -> NativeSshService, + private val connection: SharedSshConnection, private val runCommand: (HerdrStreamMode, Int, Int) -> String, private val scope: CoroutineScope, ) { @@ -58,7 +59,7 @@ class HerdrPaneHost( } private data class PendingCommand( - val service: NativeSshService, + val channelId: Int, val json: String, ) @@ -73,7 +74,7 @@ class HerdrPaneHost( val state: StateFlow = _state.asStateFlow() @Volatile private var disposed = false - @Volatile private var streamService: NativeSshService? = null + @Volatile private var streamChannelId: Int? = null private var streamJob: Job? = null private var handle = 0L private var cols = 80 @@ -181,10 +182,9 @@ class HerdrPaneHost( disposed = true streamJob?.cancel() streamJob = null - streamService?.let { service -> runCatching { service.close() } } - streamService = null runBlocking { withContext(dispatcher) { + closeStreamChannel() snapshotScheduled = false if (handle != 0L) { bridge.nativeDestroy(handle) @@ -196,11 +196,10 @@ class HerdrPaneHost( dispatcher.close() } - private fun restartStream() { + private suspend fun restartStream() { streamJob?.cancel() streamJob = null - streamService?.let { service -> runCatching { service.close() } } - streamService = null + closeStreamChannel() _state.value = _state.value.copy( status = HerdrPaneStreamStatus.Connecting, @@ -215,18 +214,15 @@ class HerdrPaneHost( var mode = initialMode var retryDelayMs = 2_000L while (currentCoroutineContext().isActive && !disposed) { - var service: NativeSshService? = null + var channelId: Int? = null var failed = false try { setConnecting(mode) - service = sshFactory() val (commandCols, commandRows) = withContext(dispatcher) { cols to rows } - if (!service.openExec(runCommand(mode, commandCols, commandRows))) { - throw IllegalStateException("Remote server did not open an exec channel") - } - streamService = service + channelId = connection.openExecChannel(runCommand(mode, commandCols, commandRows)) + streamChannelId = channelId retryDelayMs = 2_000L - when (readFrames(service, mode)) { + when (readFrames(channelId, mode)) { StreamEnd.Restart -> Unit StreamEnd.ControlRefused -> { mode = HerdrStreamMode.Observe @@ -239,8 +235,10 @@ class HerdrPaneHost( failed = true setError(error.message ?: "Herdr terminal stream failed") } finally { - if (streamService === service) streamService = null - service?.let { runCatching { it.close() } } + if (streamChannelId == channelId) streamChannelId = null + channelId?.let { id -> + withContext(NonCancellable) { runCatching { connection.closeChannel(id) } } + } } if (failed && !disposed) { delay(retryDelayMs) @@ -249,12 +247,12 @@ class HerdrPaneHost( } } - private suspend fun readFrames(service: NativeSshService, mode: HerdrStreamMode): StreamEnd { + private suspend fun readFrames(channelId: Int, mode: HerdrStreamMode): StreamEnd { val buffer = StringBuilder() var lastSeq: Long? = null while (currentCoroutineContext().isActive && !disposed) { - writePendingCommands(service) - val chunk = service.read(8192) + writePendingCommands(channelId) + val chunk = connection.readChannel(channelId, 8192) if (chunk != null && chunk.isNotEmpty()) { for (line in appendHerdrNdjsonChunk(buffer, chunk.toString(Charsets.UTF_8))) { when (val message = parseHerdrStreamMessage(line)) { @@ -278,7 +276,7 @@ class HerdrPaneHost( null -> Unit } } - } else if (service.isChannelEof()) { + } else if (connection.channelEof(channelId)) { throw IllegalStateException("Herdr terminal stream ended") } else { delay(25) @@ -287,11 +285,11 @@ class HerdrPaneHost( throw CancellationException() } - private fun writePendingCommands(service: NativeSshService) { + private suspend fun writePendingCommands(channelId: Int) { while (true) { val pending = pendingCommands.tryReceive().getOrNull() ?: return - if (pending.service === service) { - service.write((pending.json + "\n").toByteArray(Charsets.UTF_8)) + if (pending.channelId == channelId) { + connection.writeChannel(channelId, (pending.json + "\n").toByteArray(Charsets.UTF_8)) } } } @@ -348,8 +346,14 @@ class HerdrPaneHost( private fun sendCommand(json: String, allowReadOnly: Boolean = false) { if (disposed || (!allowReadOnly && _state.value.readOnly)) return - val service = streamService ?: return - pendingCommands.trySend(PendingCommand(service, json)) + val channelId = streamChannelId ?: return + pendingCommands.trySend(PendingCommand(channelId, json)) + } + + private suspend fun closeStreamChannel() { + val channelId = streamChannelId ?: return + streamChannelId = null + runCatching { connection.closeChannel(channelId) } } private fun requestSnapshot(force: Boolean = false) { diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index 58e90b93..ea8e32d3 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -21,6 +21,7 @@ import com.jossephus.chuchu.service.multiplexer.appendHerdrStreamChunk import com.jossephus.chuchu.service.multiplexer.parseHerdrSnapshot import com.jossephus.chuchu.service.ssh.HostKeyStore import com.jossephus.chuchu.service.ssh.NativeSshService +import com.jossephus.chuchu.service.ssh.SharedSshConnection import com.jossephus.chuchu.service.ssh.TailscaleStatusChecker import java.util.concurrent.Executors import kotlinx.coroutines.CancellationException @@ -45,6 +46,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject @@ -147,6 +150,8 @@ class TerminalSessionEngine( private var lastConnectionParams: ConnectionParams? = null private var reconnectJob: Job? = null private var disconnectRequested = false + private var herdrConnection: SharedSshConnection? = null + private val herdrConnectionMutex = Mutex() private var herdrStreamJob: Job? = null @Volatile private var herdrStreamService: NativeSshService? = null private val herdrPoke = Channel(Channel.CONFLATED) @@ -689,20 +694,7 @@ class TerminalSessionEngine( val host = HerdrPaneHost( paneId = paneId, - sshFactory = { - NativeSshService(hostKeyPolicy = ::verifyHostKey).also { service -> - service.connect( - host = params.host, - port = params.port, - username = params.username, - authMethod = params.authMethod, - password = if (params.authMethod == AuthMethod.Password) params.password else "", - publicKeyOpenSsh = params.publicKeyOpenSsh, - privateKeyPem = params.privateKeyPem, - keyPassphrase = params.keyPassphrase, - ) - } - }, + connection = ensureHerdrConnection(params), runCommand = { mode, commandCols, commandRows -> HerdrMultiplexer.terminalSessionCommand(paneId, commandCols, commandRows, mode) }, @@ -749,8 +741,32 @@ class TerminalSessionEngine( _herdrPaneHosts.value.values.forEach(HerdrPaneHost::dispose) _herdrPaneHosts.value = emptyMap() herdrFocusedPaneId = null + herdrConnection?.close() + herdrConnection = null } + private suspend fun ensureHerdrConnection(params: ConnectionParams): SharedSshConnection = + herdrConnectionMutex.withLock { + val current = herdrConnection + if (current?.connected == true) return@withLock current + current?.close() + herdrConnection = null + val connection: SharedSshConnection = SharedSshConnection( + host = params.host, + port = params.port, + username = params.username, + authMethod = params.authMethod, + password = if (params.authMethod == AuthMethod.Password) params.password else "", + publicKeyOpenSsh = params.publicKeyOpenSsh, + privateKeyPem = params.privateKeyPem, + keyPassphrase = params.keyPassphrase, + hostKeyPolicy = ::verifyHostKey, + ) + connection.connect() + herdrConnection = connection + connection + } + private fun cancelHostKeyPrompt() { hostKeyDecision?.cancel() hostKeyDecision = null @@ -1169,7 +1185,13 @@ class TerminalSessionEngine( stderr = "Herdr control is only available for an active Herdr SSH session", ) } - val result = runMultiplexerCommand(params, command()) + val connection = runCatching { ensureHerdrConnection(params) }.getOrNull() + val result = + if (connection?.connected == true) { + connection.runCommand(command()) + } else { + runMultiplexerCommand(params, command()) + } pokeHerdr() result } diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/ssh/SharedSshConnectionTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/ssh/SharedSshConnectionTest.kt new file mode 100644 index 00000000..5be77446 --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/ssh/SharedSshConnectionTest.kt @@ -0,0 +1,32 @@ +package com.jossephus.chuchu.service.ssh + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SharedSshConnectionTest { + @Test + fun exitEnvelopePreservesCommandAndExitStatus() { + assertEquals( + "herdr focus-tab abc; printf '\nCHUCHU_EXIT:%s\n' \"\$?\"", + SharedSshConnection.withExitEnvelope("herdr focus-tab abc"), + ) + } + + @Test + fun parsesExitEnvelopeAndKeepsOutput() { + val result = SharedSshConnection.parseCommandEnvelope("done\nCHUCHU_EXIT:7\n") + + assertEquals(7, result.exitCode) + assertEquals("done", result.stdout) + assertEquals("", result.stderr) + } + + @Test + fun missingExitEnvelopeIsAProtocolFailure() { + val result = SharedSshConnection.parseCommandEnvelope("partial output") + + assertEquals(125, result.exitCode) + assertEquals("partial output", result.stdout) + assertEquals("Missing command exit marker", result.stderr) + } +} From 0907e23873223e23530e93aa984e553bb9a24d35 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 13:02:14 +0300 Subject: [PATCH 19/42] fix: render herdr panes at native size instead of letterboxed Split panes used fitSnapshotToCanvas=true (the command-palette preview mode), which scales the grid to fit and letterboxes when the grid aspect differs from the pane rect. Interactive panes should render at native cell size like the main terminal (false); each pane's grid is already resized to fill its rect via onResize, so it fills without bars. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt index 18deef2e..17a5cfc7 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt @@ -124,7 +124,7 @@ private fun HerdrSplitPane( } else { TerminalCanvas( snapshot = snapshot, - fitSnapshotToCanvas = true, + fitSnapshotToCanvas = false, terminalHandle = paneState.handle, enableGestures = focused, fontSizeSp = fontSizeSp, From ce803c9209e7110495b002f970c68f5bf39298d0 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 13:17:31 +0300 Subject: [PATCH 20/42] fix: sync herdr pane geometry after the channel opens A pane's real-size onResize often fires while its stream channel is still connecting, so the resize command was dropped and the pane stayed at the bootstrap 80x24 forever (content overflowed narrow split rects). After a channel opens, re-send a terminal.resize with the latest known geometry so herdr renders at the pane's actual size. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../chuchu/service/terminal/HerdrPaneHost.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt index 913ac073..db5a0faf 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt @@ -222,6 +222,7 @@ class HerdrPaneHost( channelId = connection.openExecChannel(runCommand(mode, commandCols, commandRows)) streamChannelId = channelId retryDelayMs = 2_000L + syncViewport(channelId, commandCols, commandRows) when (readFrames(channelId, mode)) { StreamEnd.Restart -> Unit StreamEnd.ControlRefused -> { @@ -285,6 +286,16 @@ class HerdrPaneHost( throw CancellationException() } + private suspend fun syncViewport(channelId: Int, openedCols: Int, openedRows: Int) { + val (currentCols, currentRows) = withContext(dispatcher) { cols to rows } + if (currentCols != openedCols || currentRows != openedRows) { + connection.writeChannel( + channelId, + (herdrResizeJson(currentCols, currentRows) + "\n").toByteArray(Charsets.UTF_8), + ) + } + } + private suspend fun writePendingCommands(channelId: Int) { while (true) { val pending = pendingCommands.tryReceive().getOrNull() ?: return From d50619763ec7b6bbabd90009c5e535c434bbc207 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 13:21:00 +0300 Subject: [PATCH 21/42] fix: size herdr split panes by edges so the last pane fits Panes were sized independently by width, so rounding accumulated and the last pane extended past the screen edge, clipping its content. Compute each pane's box from its scaled left/right and top/bottom edges instead, so adjacent panes share exact borders and the rightmost/bottom pane ends exactly at the container edge. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSplitLayout.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt index 17a5cfc7..f563e6d9 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt @@ -56,13 +56,18 @@ fun HerdrSplitLayout( val paneModifier = if (layout.zoomed) { Modifier.fillMaxSize() } else { + val left = scaledOffset(layoutPane.rect.x - layout.area.x, layout.area.width, maxWidth) + val right = scaledOffset( + layoutPane.rect.x + layoutPane.rect.width - layout.area.x, layout.area.width, maxWidth, + ) + val top = scaledOffset(layoutPane.rect.y - layout.area.y, layout.area.height, maxHeight) + val bottom = scaledOffset( + layoutPane.rect.y + layoutPane.rect.height - layout.area.y, layout.area.height, maxHeight, + ) Modifier - .offset( - x = scaledOffset(layoutPane.rect.x - layout.area.x, layout.area.width, maxWidth), - y = scaledOffset(layoutPane.rect.y - layout.area.y, layout.area.height, maxHeight), - ) - .width(scaledSize(layoutPane.rect.width, layout.area.width, maxWidth)) - .height(scaledSize(layoutPane.rect.height, layout.area.height, maxHeight)) + .offset(x = left, y = top) + .width((right - left).coerceAtLeast(0.dp)) + .height((bottom - top).coerceAtLeast(0.dp)) .padding(0.5.dp) } HerdrSplitPane( @@ -174,6 +179,3 @@ private fun HerdrSplitPane( private fun scaledOffset(value: Int, extent: Int, container: Dp): Dp = if (extent > 0) container * (value.toFloat() / extent) else 0.dp - -private fun scaledSize(value: Int, extent: Int, container: Dp): Dp = - if (extent > 0) (container * (value.toFloat() / extent)).coerceAtLeast(0.dp) else 0.dp From 1364bfba7fd5843347690e9f62974120d6fff46a Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 13:23:53 +0300 Subject: [PATCH 22/42] fix: correct herdr pane scroll direction and tap-to-focus keyboard Scroll delta mapped to the reversed herdr scroll direction; flip it so dragging scrolls content the natural way. Tapping a pane always raised the soft keyboard, making focus switching in a split annoying; now tapping a non-focused pane only focuses it, and tapping the already focused pane raises the keyboard for typing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrTerminalStream.kt | 4 ++-- .../chuchu/ui/screens/Terminal/HerdrSplitLayout.kt | 12 ++++++------ .../service/multiplexer/HerdrTerminalStreamTest.kt | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt index 95b06a61..64e58471 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt @@ -102,9 +102,9 @@ fun herdrScrollJson(direction: HerdrScrollDirection, lines: Int): String = fun herdrScrollCommand(delta: Int): Pair = if (delta >= 0) { - HerdrScrollDirection.Up to delta + HerdrScrollDirection.Down to delta } else { - HerdrScrollDirection.Down to -delta + HerdrScrollDirection.Up to -delta } fun frameDisposition(lastSeq: Long?, frame: HerdrTerminalFrame): FrameDisposition = diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt index f563e6d9..84048aae 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt @@ -143,8 +143,11 @@ private fun HerdrSplitPane( onPaneViewport(paneId, cols, rows, cellWidth, cellHeight, widthPx, heightPx) }, onTap = { - onPaneTap(paneId) - requestInputFocus() + if (focused) { + requestInputFocus() + } else { + onPaneTap(paneId) + } }, onPrimaryClick = { _, _ -> }, onAppSelectionDrag = { _, _, _ -> }, @@ -156,10 +159,7 @@ private fun HerdrSplitPane( } if (!focused) { Box( - modifier = Modifier.fillMaxSize().clickable { - onPaneTap(paneId) - requestInputFocus() - }, + modifier = Modifier.fillMaxSize().clickable { onPaneTap(paneId) }, ) } if (paneState?.readOnly == true && focused) { diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt index c8638d40..3f6d61d4 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt @@ -105,8 +105,8 @@ class HerdrTerminalStreamTest { } @Test - fun mapsPositiveScrollDeltaToHistoryUp() { - assertEquals(HerdrScrollDirection.Up to 3, herdrScrollCommand(3)) - assertEquals(HerdrScrollDirection.Down to 2, herdrScrollCommand(-2)) + fun mapsScrollDeltaToDirection() { + assertEquals(HerdrScrollDirection.Down to 3, herdrScrollCommand(3)) + assertEquals(HerdrScrollDirection.Up to 2, herdrScrollCommand(-2)) } } From ecd789cb72024bb1639da2608cfd16628e75232b Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 13:37:35 +0300 Subject: [PATCH 23/42] fix: streamline native herdr connect and force the tab strip Native herdr previously ran the tmux/zmx-style multiplexer session-picker preflight on connect, which showed an empty "no terminal sessions" state until an SSH round-trip resolved a session (or the user tapped "new connection"). Native mode always attaches to the default session, so open the tab directly and skip the preflight. Also force the tab strip when the active tab is native herdr, since the split UI and herdr tab navigation depend on it and break under the palette tab interface. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../chuchu/ui/screens/Terminal/TerminalScreen.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index 345d4ca0..f9415db7 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -326,7 +326,11 @@ fun TerminalScreen( darkThemeName = currentTheme, lightThemeName = lightThemeName, ) - val tabMode by settingsRepo.terminalTabMode.collectAsStateWithLifecycle() + val tabModeSetting by settingsRepo.terminalTabMode.collectAsStateWithLifecycle() + // Native herdr splits rely on the tab strip (herdr tabs live there); force it + // regardless of the general tab-interface setting. + val tabMode = + if (activeTab?.spec?.usesHerdrNativeMode == true) TerminalTabMode.Strip else tabModeSetting val currentAccessoryLayoutIds by settingsRepo.accessoryLayoutIds.collectAsStateWithLifecycle() val useSingleRowAccessoryBar by settingsRepo.accessoryBarSingleRow.collectAsStateWithLifecycle() val currentTerminalCustomKeyGroups by @@ -477,6 +481,10 @@ fun TerminalScreen( passphraseFromPicker = fromPicker pendingTabSpec = preparedSpec showPassphrasePrompt = true + } else if (preparedSpec.usesHerdrNativeMode) { + // Native herdr always attaches to the default session; skip the + // multiplexer session-picker preflight so it connects immediately. + vm.openTab(preparedSpec.copy(multiplexerSessionName = "default")) } else if (preparedSpec.usesRuntimeMultiplexer) { vm.initiateMultiplexerOpen(preparedSpec) } else { From a28ac535c6e5ed9f8beab81affbc827248e8cad6 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 13:50:32 +0300 Subject: [PATCH 24/42] feat: show server connections in the herdr switcher home Native herdr mode replaced the cross-server tab strip with herdr's own tabs, leaving no easy way to reach other server connections. Add a "connections" section at the top of the herdr home listing every open connection tab (tap to switch) plus a "server list" entry, so the home is the single place to move between servers and herdr workspaces. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSwitcherHome.kt | 90 ++++++++++++++++--- .../ui/screens/Terminal/TerminalScreen.kt | 4 + 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt index f5b24820..14adfb2a 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -25,6 +25,7 @@ import com.jossephus.chuchu.service.multiplexer.HerdrAgent import com.jossephus.chuchu.service.multiplexer.HerdrAgentStatus import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot import com.jossephus.chuchu.service.multiplexer.HerdrWorkspace +import com.jossephus.chuchu.service.terminal.TabSession import com.jossephus.chuchu.ui.components.ChuText import com.jossephus.chuchu.ui.theme.ChuColorPalette import com.jossephus.chuchu.ui.theme.ChuColors @@ -38,6 +39,10 @@ fun HerdrSwitcherHome( colors: ChuColorPalette, modifier: Modifier = Modifier, sessionHint: String? = null, + connections: List = emptyList(), + activeConnectionId: String? = null, + onSelectConnection: (String) -> Unit = {}, + onOpenServerList: () -> Unit = {}, ) { val typography = ChuTypography.current val workspaces = snapshot.workspaces.sortedBy { it.number } @@ -58,22 +63,83 @@ fun HerdrSwitcherHome( ) } - if (workspaces.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ChuText("no herdr workspaces", style = typography.labelSmall, color = colors.textMuted) + LazyColumn(modifier = Modifier.fillMaxSize()) { + item(key = "connections") { + HerdrConnections( + connections = connections, + activeConnectionId = activeConnectionId, + onSelectConnection = onSelectConnection, + onOpenServerList = onOpenServerList, + ) + } + if (workspaces.isEmpty()) { + item(key = "no-workspaces") { + ChuText( + "no herdr workspaces", + style = typography.labelSmall, + color = colors.textMuted, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + ) + } + } else { + items(workspaces, key = { it.workspaceId }) { workspace -> + HerdrSwitcherWorkspace( + workspace = workspace, + agents = snapshot.agents.filter { it.workspaceId == workspace.workspaceId }, + onEnterWorkspace = onEnterWorkspace, + onEnterAgent = onEnterAgent, + ) + } } - return } + } +} - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(workspaces, key = { it.workspaceId }) { workspace -> - HerdrSwitcherWorkspace( - workspace = workspace, - agents = snapshot.agents.filter { it.workspaceId == workspace.workspaceId }, - onEnterWorkspace = onEnterWorkspace, - onEnterAgent = onEnterAgent, +@Composable +private fun HerdrConnections( + connections: List, + activeConnectionId: String?, + onSelectConnection: (String) -> Unit, + onOpenServerList: () -> Unit, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ChuText("connections", style = typography.label, color = colors.textSecondary, modifier = Modifier.weight(1f)) + ChuText( + "server list", + style = typography.labelSmall, + color = colors.accent, + modifier = Modifier.clickable { onOpenServerList() }.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + connections.forEach { tab -> + val active = tab.id == activeConnectionId + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .background(if (active) colors.surfaceVariant else Color.Transparent) + .border( + 1.dp, + if (active) colors.accent.copy(alpha = 0.5f) else colors.border.copy(alpha = 0.2f), ) - } + .clickable { onSelectConnection(tab.id) } + .padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChuText( + terminalTabDisplayLabel(tab), + style = typography.body, + color = if (active) colors.accent else colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index f9415db7..87398b6e 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -1298,6 +1298,10 @@ fun TerminalScreen( onEnterAgent = vm::onEnterHerdrAgent, colors = colors, sessionHint = activeTab?.let(::terminalTabDisplayLabel), + connections = tabs, + activeConnectionId = activeTabId, + onSelectConnection = { id -> vm.selectTab(id) }, + onOpenServerList = onBack, modifier = Modifier.fillMaxSize(), ) } From e57296350948bdfbc21f8461febf539d19a2fe1a Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 13:59:43 +0300 Subject: [PATCH 25/42] fix: label herdr home connections by server name, not tab alias The connections list and session hint showed chuchu's random per-tab alias (e.g. "amber-signal"), which didn't match the server names shown in the server list and tab endpoints. Show the server name (tabLabel) instead, falling back to the alias only as a secondary hint when more than one connection targets the same host. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSwitcherHome.kt | 18 +++++++++++++++++- .../ui/screens/Terminal/TerminalScreen.kt | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt index 14adfb2a..88adf03b 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -117,8 +117,14 @@ private fun HerdrConnections( modifier = Modifier.clickable { onOpenServerList() }.padding(horizontal = 6.dp, vertical = 4.dp), ) } + val duplicateHosts = connections + .groupBy { it.spec.hostId ?: it.spec.sessionKey } + .filterValues { it.size > 1 } + .keys connections.forEach { tab -> val active = tab.id == activeConnectionId + val serverName = tab.spec.tabLabel + val ambiguous = (tab.spec.hostId ?: tab.spec.sessionKey) in duplicateHosts Row( modifier = Modifier .fillMaxWidth() @@ -134,12 +140,22 @@ private fun HerdrConnections( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { ChuText( - terminalTabDisplayLabel(tab), + serverName, style = typography.body, color = if (active) colors.accent else colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) + if (ambiguous) { + ChuText( + terminalTabAlias(tab), + style = typography.labelSmall, + color = colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index 87398b6e..a62ae436 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -1297,7 +1297,7 @@ fun TerminalScreen( onEnterWorkspace = vm::onEnterHerdrWorkspace, onEnterAgent = vm::onEnterHerdrAgent, colors = colors, - sessionHint = activeTab?.let(::terminalTabDisplayLabel), + sessionHint = activeTab?.spec?.tabLabel, connections = tabs, activeConnectionId = activeTabId, onSelectConnection = { id -> vm.selectTab(id) }, From 4879f9bd520316a5b8dfcb9313febfef0a93268b Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 14:06:16 +0300 Subject: [PATCH 26/42] fix: summarize workspace agent status by counts, not a single rollup The workspace header showed herdr's single aggregate agent_status, which read "working" even when only one of several agents was working and "unknown" for workspaces with no agents. Compute the summary from the workspace's actual agents instead: a per-status count (e.g. "2 working 1 idle"), and nothing at all when a workspace has no agents. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSwitcherHome.kt | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt index 88adf03b..5e4b7699 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -186,7 +186,7 @@ private fun HerdrSwitcherWorkspace( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { ChuText(label, style = typography.label, color = colors.textPrimary, modifier = Modifier.weight(1f)) - HerdrSwitcherStatus(workspace.agentStatus) + HerdrAgentStatusSummary(agents) ChuText( "${workspace.tabCount} tabs · ${workspace.paneCount} panes", style = typography.labelSmall, @@ -198,6 +198,38 @@ private fun HerdrSwitcherWorkspace( } } +@Composable +private fun HerdrAgentStatusSummary(agents: List) { + val colors = ChuColors.current + val typography = ChuTypography.current + val order = listOf( + HerdrAgentStatus.Blocked, + HerdrAgentStatus.Working, + HerdrAgentStatus.Done, + HerdrAgentStatus.Idle, + ) + val counts = order.mapNotNull { status -> + val count = agents.count { it.agentStatus == status } + if (count > 0) status to count else null + } + if (counts.isEmpty()) return + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + counts.forEach { (status, count) -> + val color = herdrAgentStatusColor(status, colors) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(modifier = Modifier.size(6.dp).background(color)) + ChuText("$count ${status.name.lowercase()}", style = typography.labelSmall, color = color) + } + } + } +} + @Composable private fun HerdrSwitcherAgent( agent: HerdrAgent, From 8286493f0f686f8aff16c5b806b1ccaf3168ae5c Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 14:08:56 +0300 Subject: [PATCH 27/42] feat: add a "needs you" attention section to the herdr home Per herdr's status semantics, blocked (waiting for input) and done (finished, result unseen) are the states that require the user, while idle/working are passive. Surface them at the top of the home: a "needs you" list of blocked/done agents across the session, blocked first, tap to jump straight to the pane. Hidden when nothing needs attention. This turns the home into a triage screen and complements the existing blocked/done notifications. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSwitcherHome.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt index 5e4b7699..3c18e0ab 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -63,7 +63,16 @@ fun HerdrSwitcherHome( ) } + val needsYou = snapshot.agents + .filter { it.agentStatus == HerdrAgentStatus.Blocked || it.agentStatus == HerdrAgentStatus.Done } + .sortedBy { if (it.agentStatus == HerdrAgentStatus.Blocked) 0 else 1 } + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (needsYou.isNotEmpty()) { + item(key = "needs-you") { + HerdrNeedsYou(agents = needsYou, onEnterAgent = onEnterAgent) + } + } item(key = "connections") { HerdrConnections( connections = connections, @@ -95,6 +104,54 @@ fun HerdrSwitcherHome( } } +@Composable +private fun HerdrNeedsYou( + agents: List, + onEnterAgent: (agentPaneId: String, tabId: String) -> Unit, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + + ChuText( + "needs you", + style = typography.label, + color = colors.textSecondary, + modifier = Modifier.padding(start = 12.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + ) + agents.forEach { agent -> + val color = herdrAgentStatusColor(agent.agentStatus, colors) + val name = agent.agent?.takeIf { it.isNotBlank() } ?: "shell" + val title = agent.terminalTitleStripped?.takeIf { it.isNotBlank() } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .border(1.dp, color.copy(alpha = 0.55f)) + .clickable { onEnterAgent(agent.paneId, agent.tabId) } + .padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.size(6.dp).background(color)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ChuText(name, style = typography.body, color = colors.textSecondary) + ChuText(agent.agentStatus.name.lowercase(), style = typography.labelSmall, color = color) + } + title?.let { + ChuText( + it, + style = typography.labelSmall, + color = colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + @Composable private fun HerdrConnections( connections: List, From c3fc6cba457135c836776af0cbbc218b94177ffe Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 14:26:25 +0300 Subject: [PATCH 28/42] polish: herdr home density, pluralization, and title cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pluralize tab/pane counts ("1 tab · 1 pane"). - Collapse workspaces to a header + status summary by default; auto-expand the focused workspace and any with a blocked/done agent, so the home stays scannable while surfacing what matters. - Drop the agent-row cwd when it just repeats the workspace name, and strip the repeated workspace prefix/suffix from agent titles. - Add spacing between workspace groups. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSwitcherHome.kt | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt index 3c18e0ab..797ad2e4 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -92,9 +92,14 @@ fun HerdrSwitcherHome( } } else { items(workspaces, key = { it.workspaceId }) { workspace -> + val workspaceAgents = snapshot.agents.filter { it.workspaceId == workspace.workspaceId } + val hasAttention = workspaceAgents.any { + it.agentStatus == HerdrAgentStatus.Blocked || it.agentStatus == HerdrAgentStatus.Done + } HerdrSwitcherWorkspace( workspace = workspace, - agents = snapshot.agents.filter { it.workspaceId == workspace.workspaceId }, + agents = workspaceAgents, + expanded = workspace.focused || hasAttention, onEnterWorkspace = onEnterWorkspace, onEnterAgent = onEnterAgent, ) @@ -121,7 +126,7 @@ private fun HerdrNeedsYou( agents.forEach { agent -> val color = herdrAgentStatusColor(agent.agentStatus, colors) val name = agent.agent?.takeIf { it.isNotBlank() } ?: "shell" - val title = agent.terminalTitleStripped?.takeIf { it.isNotBlank() } + val title = agent.terminalTitleStripped?.let { cleanAgentTitle(it, "") }?.takeIf { it.isNotBlank() } Row( modifier = Modifier .fillMaxWidth() @@ -221,6 +226,7 @@ private fun HerdrConnections( private fun HerdrSwitcherWorkspace( workspace: HerdrWorkspace, agents: List, + expanded: Boolean, onEnterWorkspace: (String) -> Unit, onEnterAgent: (agentPaneId: String, tabId: String) -> Unit, ) { @@ -231,7 +237,7 @@ private fun HerdrSwitcherWorkspace( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp) + .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 3.dp) .background(if (workspace.focused) colors.surfaceVariant else Color.Transparent) .border( 1.dp, @@ -245,13 +251,15 @@ private fun HerdrSwitcherWorkspace( ChuText(label, style = typography.label, color = colors.textPrimary, modifier = Modifier.weight(1f)) HerdrAgentStatusSummary(agents) ChuText( - "${workspace.tabCount} tabs · ${workspace.paneCount} panes", + "${pluralCount(workspace.tabCount, "tab")} · ${pluralCount(workspace.paneCount, "pane")}", style = typography.labelSmall, color = colors.textMuted, ) } - agents.forEach { agent -> - HerdrSwitcherAgent(agent, onEnterAgent) + if (expanded) { + agents.forEach { agent -> + HerdrSwitcherAgent(agent, label, onEnterAgent) + } } } @@ -290,12 +298,15 @@ private fun HerdrAgentStatusSummary(agents: List) { @Composable private fun HerdrSwitcherAgent( agent: HerdrAgent, + workspaceLabel: String, onEnterAgent: (agentPaneId: String, tabId: String) -> Unit, ) { val colors = ChuColors.current val typography = ChuTypography.current val name = agent.agent?.takeIf { it.isNotBlank() } ?: "shell" - val cwd = agent.cwd?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() } + val cwd = agent.cwd?.trimEnd('/')?.substringAfterLast('/') + ?.takeIf { it.isNotBlank() && !it.equals(workspaceLabel, ignoreCase = true) } + val title = agent.terminalTitleStripped?.let { cleanAgentTitle(it, workspaceLabel) }?.takeIf { it.isNotBlank() } Row( modifier = Modifier @@ -317,9 +328,9 @@ private fun HerdrSwitcherAgent( Spacer(modifier = Modifier.width(8.dp)) HerdrSwitcherStatus(agent.agentStatus) } - agent.terminalTitleStripped?.takeIf { it.isNotBlank() }?.let { title -> + title?.let { ChuText( - title, + it, style = typography.labelSmall, color = colors.textMuted, maxLines = 1, @@ -350,3 +361,19 @@ private fun HerdrSwitcherStatus(status: HerdrAgentStatus) { ChuText(status.name.lowercase(), style = typography.labelSmall, color = color) } } + +private fun pluralCount(count: Int, unit: String): String = + "$count $unit${if (count == 1) "" else "s"}" + +private fun cleanAgentTitle(title: String, workspaceLabel: String): String { + var cleaned = title + val colon = cleaned.indexOf(": ") + if (colon in 1..40) cleaned = cleaned.substring(colon + 2) + if (workspaceLabel.isNotBlank()) { + val suffix = " - $workspaceLabel" + if (cleaned.endsWith(suffix, ignoreCase = true)) { + cleaned = cleaned.dropLast(suffix.length) + } + } + return cleaned.trim().ifBlank { title } +} From 1dff2a25af9bd33c356986c3616ba93fd5a6f512 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 14:31:44 +0300 Subject: [PATCH 29/42] fix: don't crash when a herdr control verb hits a dead connection Tapping a herdr agent/tab/workspace runs a focus verb over the shared SSH connection. If that connection had gone stale (e.g. the phone's IP changed), openExecChannel threw "Channel open timed out" which propagated uncaught to the main thread and crashed the app. Catch failures in the control path: surface them as an action error, and drop the stale shared connection so it reconnects on the next command. Also guard the ViewModel launch so no control-verb exception can reach the main thread. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/terminal/TerminalSessionEngine.kt | 16 ++++++++++++---- .../ui/screens/Terminal/TerminalViewModel.kt | 5 ++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index ea8e32d3..1dd4dbf9 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -1187,10 +1187,18 @@ class TerminalSessionEngine( } val connection = runCatching { ensureHerdrConnection(params) }.getOrNull() val result = - if (connection?.connected == true) { - connection.runCommand(command()) - } else { - runMultiplexerCommand(params, command()) + try { + if (connection?.connected == true) { + connection.runCommand(command()) + } else { + runMultiplexerCommand(params, command()) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + herdrConnection?.close() + herdrConnection = null + MultiplexerCommandResult(1, "", error.message ?: "Herdr command failed") } pokeHerdr() result diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index 0893372a..9e4cfefa 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -546,7 +546,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application ) { val engine = sessionRepository.activeTab.value?.engine ?: return viewModelScope.launch { - updateHerdrActionError(command(engine)) + val result = runCatching { command(engine) }.getOrElse { + MultiplexerCommandResult(1, "", it.message ?: "Herdr command failed") + } + updateHerdrActionError(result) } } From 84b4544f3cc82c43c20b1800049c816a71d536c3 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 15:34:27 +0300 Subject: [PATCH 30/42] feat: allow herdr commands to target a named session Thread an optional herdr session name through the snapshot stream, pane terminal streams, and control verbs, emitting `herdr --session ` when the configured session is not the default. Behavior is unchanged for the default session (no flag emitted); this is groundwork for a per-host herdr session selector. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrMultiplexer.kt | 30 +++++++++++-------- .../service/terminal/TerminalSessionEngine.kt | 14 ++++----- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt index c2afa457..52660684 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -69,9 +69,12 @@ object HerdrMultiplexer : Multiplexer { localSessionNames: Collection, ): String = "default" - fun snapshotStreamCommand(): String = + private fun herdr(session: String?): String = + if (session.isNullOrBlank() || session == "default") "herdr " else "herdr --session ${shellQuote(session)} " + + fun snapshotStreamCommand(session: String? = null): String = pathPrelude + - "while IFS= read -r _; do printf 'CHUCHU_SNAP_BEGIN\\n'; herdr api snapshot 2>/dev/null; " + + "while IFS= read -r _; do printf 'CHUCHU_SNAP_BEGIN\\n'; ${herdr(session)}api snapshot 2>/dev/null; " + "printf '\\nCHUCHU_SNAP_END\\n'; done" fun terminalSessionCommand( @@ -79,6 +82,7 @@ object HerdrMultiplexer : Multiplexer { cols: Int, rows: Int, mode: HerdrStreamMode, + session: String? = null, ): String { val command = when (mode) { HerdrStreamMode.Control, @@ -87,23 +91,23 @@ object HerdrMultiplexer : Multiplexer { HerdrStreamMode.Observe -> "observe" } val takeover = if (mode == HerdrStreamMode.ControlTakeover) " --takeover" else "" - return pathPrelude + "herdr terminal session $command ${shellQuote(paneId)} --cols $cols --rows $rows$takeover" + return pathPrelude + "${herdr(session)}terminal session $command ${shellQuote(paneId)} --cols $cols --rows $rows$takeover" } - fun focusTabCommand(tabId: String): String = - pathPrelude + "herdr tab focus ${shellQuote(tabId)}" + fun focusTabCommand(tabId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}tab focus ${shellQuote(tabId)}" - fun focusWorkspaceCommand(workspaceId: String): String = - pathPrelude + "herdr workspace focus ${shellQuote(workspaceId)}" + fun focusWorkspaceCommand(workspaceId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}workspace focus ${shellQuote(workspaceId)}" - fun focusPaneCommand(paneId: String): String = - pathPrelude + "herdr agent focus ${shellQuote(paneId)}" + fun focusPaneCommand(paneId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}agent focus ${shellQuote(paneId)}" - fun createTabCommand(workspaceId: String): String = - pathPrelude + "herdr tab create --workspace ${shellQuote(workspaceId)} --focus" + fun createTabCommand(workspaceId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}tab create --workspace ${shellQuote(workspaceId)} --focus" - fun closeTabCommand(tabId: String): String = - pathPrelude + "herdr tab close ${shellQuote(tabId)}" + fun closeTabCommand(tabId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}tab close ${shellQuote(tabId)}" private fun shellQuote(value: String): String = "'" + value.replace("'", "'\\''") + "'" diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index 1dd4dbf9..4c0cab31 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -623,19 +623,19 @@ class TerminalSessionEngine( } suspend fun herdrFocusTab(tabId: String): MultiplexerCommandResult = - runHerdrControl { HerdrMultiplexer.focusTabCommand(tabId) } + runHerdrControl { HerdrMultiplexer.focusTabCommand(tabId, lastConnectionParams?.multiplexerSessionName) } suspend fun herdrFocusWorkspace(workspaceId: String): MultiplexerCommandResult = - runHerdrControl { HerdrMultiplexer.focusWorkspaceCommand(workspaceId) } + runHerdrControl { HerdrMultiplexer.focusWorkspaceCommand(workspaceId, lastConnectionParams?.multiplexerSessionName) } suspend fun herdrFocusPane(paneId: String): MultiplexerCommandResult = - runHerdrControl { HerdrMultiplexer.focusPaneCommand(paneId) } + runHerdrControl { HerdrMultiplexer.focusPaneCommand(paneId, lastConnectionParams?.multiplexerSessionName) } suspend fun herdrCreateTab(workspaceId: String): MultiplexerCommandResult = - runHerdrControl { HerdrMultiplexer.createTabCommand(workspaceId) } + runHerdrControl { HerdrMultiplexer.createTabCommand(workspaceId, lastConnectionParams?.multiplexerSessionName) } suspend fun herdrCloseTab(tabId: String): MultiplexerCommandResult = - runHerdrControl { HerdrMultiplexer.closeTabCommand(tabId) } + runHerdrControl { HerdrMultiplexer.closeTabCommand(tabId, lastConnectionParams?.multiplexerSessionName) } fun disconnect() { disconnectRequested = true @@ -696,7 +696,7 @@ class TerminalSessionEngine( paneId = paneId, connection = ensureHerdrConnection(params), runCommand = { mode, commandCols, commandRows -> - HerdrMultiplexer.terminalSessionCommand(paneId, commandCols, commandRows, mode) + HerdrMultiplexer.terminalSessionCommand(paneId, commandCols, commandRows, mode, params.multiplexerSessionName) }, scope = scope, ) @@ -1234,7 +1234,7 @@ class TerminalSessionEngine( privateKeyPem = params.privateKeyPem, keyPassphrase = params.keyPassphrase, ) - if (!service.openExec(HerdrMultiplexer.snapshotStreamCommand())) { + if (!service.openExec(HerdrMultiplexer.snapshotStreamCommand(params.multiplexerSessionName))) { throw IllegalStateException("Remote server did not open an exec channel") } retryDelayMs = 2_000L From 21896b46530acf4b1ad381156c6cb38f8969d930 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 15:42:13 +0300 Subject: [PATCH 31/42] feat: create herdr workspaces from the switcher home Add a "+ new" action to the home's workspaces header that prompts for an optional name and creates a focused herdr workspace (herdr workspace create --focus [--label]). Previously workspaces could only be created from the herdr TUI/CLI on the host; the app could enter and switch workspaces but not add one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrMultiplexer.kt | 5 ++++ .../service/terminal/TerminalSessionEngine.kt | 3 +++ .../ui/screens/Terminal/HerdrSwitcherHome.kt | 15 +++++++++++ .../ui/screens/Terminal/TerminalScreen.kt | 27 +++++++++++++++++++ .../ui/screens/Terminal/TerminalViewModel.kt | 4 +++ 5 files changed, 54 insertions(+) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt index 52660684..afc7fac4 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -106,6 +106,11 @@ object HerdrMultiplexer : Multiplexer { fun createTabCommand(workspaceId: String, session: String? = null): String = pathPrelude + "${herdr(session)}tab create --workspace ${shellQuote(workspaceId)} --focus" + fun createWorkspaceCommand(label: String?, session: String? = null): String { + val labelArg = label?.takeIf { it.isNotBlank() }?.let { " --label ${shellQuote(it)}" } ?: "" + return pathPrelude + "${herdr(session)}workspace create --focus$labelArg" + } + fun closeTabCommand(tabId: String, session: String? = null): String = pathPrelude + "${herdr(session)}tab close ${shellQuote(tabId)}" diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index 4c0cab31..8e79b351 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -634,6 +634,9 @@ class TerminalSessionEngine( suspend fun herdrCreateTab(workspaceId: String): MultiplexerCommandResult = runHerdrControl { HerdrMultiplexer.createTabCommand(workspaceId, lastConnectionParams?.multiplexerSessionName) } + suspend fun herdrCreateWorkspace(label: String?): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.createWorkspaceCommand(label, lastConnectionParams?.multiplexerSessionName) } + suspend fun herdrCloseTab(tabId: String): MultiplexerCommandResult = runHerdrControl { HerdrMultiplexer.closeTabCommand(tabId, lastConnectionParams?.multiplexerSessionName) } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt index 797ad2e4..986703b6 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -43,6 +43,7 @@ fun HerdrSwitcherHome( activeConnectionId: String? = null, onSelectConnection: (String) -> Unit = {}, onOpenServerList: () -> Unit = {}, + onCreateWorkspace: () -> Unit = {}, ) { val typography = ChuTypography.current val workspaces = snapshot.workspaces.sortedBy { it.number } @@ -81,6 +82,20 @@ fun HerdrSwitcherHome( onOpenServerList = onOpenServerList, ) } + item(key = "workspaces-header") { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 8.dp, top = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ChuText("workspaces", style = typography.label, color = colors.textSecondary, modifier = Modifier.weight(1f)) + ChuText( + "+ new", + style = typography.labelSmall, + color = colors.accent, + modifier = Modifier.clickable { onCreateWorkspace() }.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } if (workspaces.isEmpty()) { item(key = "no-workspaces") { ChuText( diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index a62ae436..eae2c7de 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -348,6 +348,8 @@ fun TerminalScreen( var selectionState by remember { mutableStateOf(null) } var showPassphrasePrompt by remember { mutableStateOf(false) } var passphraseInput by remember { mutableStateOf("") } + var showCreateWorkspacePrompt by remember { mutableStateOf(false) } + var newWorkspaceName by remember { mutableStateOf("") } var pendingTabSpec by remember { mutableStateOf(null) } var passphraseFromPicker by remember { mutableStateOf(false) } var showTabSheet by remember { mutableStateOf(false) } @@ -634,6 +636,30 @@ fun TerminalScreen( } } + if (showCreateWorkspacePrompt) { + ChuDialog( + title = "New herdr workspace", + confirmLabel = "Create", + onConfirm = { + showCreateWorkspacePrompt = false + vm.onHerdrCreateWorkspace(newWorkspaceName.trim().ifBlank { null }) + newWorkspaceName = "" + }, + onDismiss = { + showCreateWorkspacePrompt = false + newWorkspaceName = "" + }, + ) { + ChuTextField( + value = newWorkspaceName, + onValueChange = { newWorkspaceName = it }, + label = "Name (optional)", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + fun showLocalShellFilesUnsupported() { val message = "Files are not supported for local shell" localShellFilesMessage = message @@ -1296,6 +1322,7 @@ fun TerminalScreen( snapshot = herdrSnapshot, onEnterWorkspace = vm::onEnterHerdrWorkspace, onEnterAgent = vm::onEnterHerdrAgent, + onCreateWorkspace = { showCreateWorkspacePrompt = true }, colors = colors, sessionHint = activeTab?.spec?.tabLabel, connections = tabs, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index 9e4cfefa..c94446f7 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -509,6 +509,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application runHerdrCommand { it.herdrCreateTab(workspaceId) } } + fun onHerdrCreateWorkspace(label: String?) { + runHerdrCommand { it.herdrCreateWorkspace(label) } + } + fun onEnterHerdrWorkspace(workspaceId: String) { _herdrHomeVisible.value = false runHerdrCommand { it.herdrFocusWorkspace(workspaceId) } From 64955dd653941cd43b387f86b40840bac0685dad Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 16:12:03 +0300 Subject: [PATCH 32/42] fix: skip the session picker for classic herdr too, not just native The connect flow only bypassed the tmux/zmx-style session-picker preflight for native herdr; classic herdr (native splits off) still ran it and showed the empty "no terminal sessions" state until the user manually tapped "new connection". herdr is single-primary-session in both modes, so any herdr host now attaches to the default session directly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../java/com/jossephus/chuchu/service/terminal/TabSpec.kt | 5 ++++- .../jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt index 1fde3366..0545fa90 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt @@ -52,8 +52,11 @@ data class TabSpec( transport != Transport.Mosh && transport != Transport.LocalShell + val usesHerdr: Boolean + get() = multiplexer == MultiplexerType.Herdr && usesRuntimeMultiplexer + val usesHerdrNativeMode: Boolean - get() = multiplexer == MultiplexerType.Herdr && usesRuntimeMultiplexer && herdrNativeMode + get() = usesHerdr && herdrNativeMode companion object { fun fromHostProfile( diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index eae2c7de..28dc644f 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -483,9 +483,10 @@ fun TerminalScreen( passphraseFromPicker = fromPicker pendingTabSpec = preparedSpec showPassphrasePrompt = true - } else if (preparedSpec.usesHerdrNativeMode) { - // Native herdr always attaches to the default session; skip the - // multiplexer session-picker preflight so it connects immediately. + } else if (preparedSpec.usesHerdr) { + // Herdr (native or classic) always attaches to the default session; + // skip the tmux/zmx-style session-picker preflight so it connects + // immediately instead of showing an empty "no sessions" state. vm.openTab(preparedSpec.copy(multiplexerSessionName = "default")) } else if (preparedSpec.usesRuntimeMultiplexer) { vm.initiateMultiplexerOpen(preparedSpec) From 83c8a5bc9d1bacf7306507e047bcb1384a046c10 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 16:16:39 +0300 Subject: [PATCH 33/42] fix: don't mirror herdr tabs in the strip for classic herdr mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip's herdr-tab mode was gated on "is a herdr host" rather than native mode, so in classic mode (native splits off) chuchu's tab strip mirrored herdr's tabs — duplicating the tab bar herdr already draws inside its own TUI. Gate it on native mode so classic mode shows normal chuchu connection tabs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index c94446f7..8b59d864 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -93,9 +93,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application val herdrUiState: StateFlow = activeTab .flatMapLatest { tab -> - if (tab != null && tab.spec.multiplexer == MultiplexerType.Herdr && - tab.spec.transport in setOf(Transport.SSH, Transport.TailscaleSSH) - ) { + if (tab != null && tab.spec.usesHerdrNativeMode) { combine(tab.engine.herdrState, _herdrActionError) { control, actionError -> HerdrUiState(control = control, enabled = true, actionError = actionError) } From 93cb5abe80d06a66ea78fe3c7b0e0326efc1c202 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 18:51:36 +0300 Subject: [PATCH 34/42] feat: add herdr pane split/close and workspace close verbs Native mode routes keystrokes to the focused pane's PTY, so herdr's own keybindings for creating/destroying panes never reach the server. Add the missing CLI control-plane verbs (pane split --direction right|down, pane close, workspace close) alongside the existing focus/create-tab verbs so these can be driven from native chuchu UI. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../service/multiplexer/HerdrMultiplexer.kt | 14 +++++++ .../service/terminal/TerminalSessionEngine.kt | 10 +++++ .../ui/screens/Terminal/TerminalViewModel.kt | 13 +++++++ .../multiplexer/HerdrMultiplexerTest.kt | 39 +++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt index afc7fac4..28fa6712 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -4,6 +4,11 @@ import com.jossephus.chuchu.model.MultiplexerType import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +enum class HerdrSplitDirection(val cli: String) { + Right("right"), + Down("down"), +} + object HerdrMultiplexer : Multiplexer { internal const val pathPrelude = "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " private val json = Json { @@ -114,6 +119,15 @@ object HerdrMultiplexer : Multiplexer { fun closeTabCommand(tabId: String, session: String? = null): String = pathPrelude + "${herdr(session)}tab close ${shellQuote(tabId)}" + fun splitPaneCommand(paneId: String, direction: HerdrSplitDirection, session: String? = null): String = + pathPrelude + "${herdr(session)}pane split ${shellQuote(paneId)} --direction ${direction.cli} --focus" + + fun closePaneCommand(paneId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}pane close ${shellQuote(paneId)}" + + fun closeWorkspaceCommand(workspaceId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}workspace close ${shellQuote(workspaceId)}" + private fun shellQuote(value: String): String = "'" + value.replace("'", "'\\''") + "'" diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index 8e79b351..2b8a5872 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -12,6 +12,7 @@ import com.jossephus.chuchu.service.mosh.NativeMoshService import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.multiplexer.HerdrMultiplexer import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot +import com.jossephus.chuchu.service.multiplexer.HerdrSplitDirection import com.jossephus.chuchu.service.multiplexer.HerdrStreamMode import com.jossephus.chuchu.service.multiplexer.MultiplexerAvailability import com.jossephus.chuchu.service.multiplexer.MultiplexerCommandResult @@ -640,6 +641,15 @@ class TerminalSessionEngine( suspend fun herdrCloseTab(tabId: String): MultiplexerCommandResult = runHerdrControl { HerdrMultiplexer.closeTabCommand(tabId, lastConnectionParams?.multiplexerSessionName) } + suspend fun herdrSplitPane(paneId: String, direction: HerdrSplitDirection): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.splitPaneCommand(paneId, direction, lastConnectionParams?.multiplexerSessionName) } + + suspend fun herdrClosePane(paneId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.closePaneCommand(paneId, lastConnectionParams?.multiplexerSessionName) } + + suspend fun herdrCloseWorkspace(workspaceId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.closeWorkspaceCommand(workspaceId, lastConnectionParams?.multiplexerSessionName) } + fun disconnect() { disconnectRequested = true reconnectJob?.cancel() diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index 8b59d864..5100c269 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -14,6 +14,7 @@ import com.jossephus.chuchu.model.MultiplexerType import com.jossephus.chuchu.model.Transport import com.jossephus.chuchu.service.multiplexer.HerdrControlState import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot +import com.jossephus.chuchu.service.multiplexer.HerdrSplitDirection import com.jossephus.chuchu.service.multiplexer.HerdrTabLayout import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry import com.jossephus.chuchu.service.multiplexer.MultiplexerCommandResult @@ -538,6 +539,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application runHerdrCommand { it.herdrCloseTab(tabId) } } + fun onHerdrSplitPane(paneId: String, direction: HerdrSplitDirection) { + runHerdrCommand { it.herdrSplitPane(paneId, direction) } + } + + fun onHerdrClosePane(paneId: String) { + runHerdrCommand { it.herdrClosePane(paneId) } + } + + fun onHerdrCloseWorkspace(workspaceId: String) { + runHerdrCommand { it.herdrCloseWorkspace(workspaceId) } + } + fun onHerdrPanelOpened() { _herdrActionError.value = null sessionRepository.activeTab.value?.engine?.pokeHerdr() diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt index a25b32c7..bac9e2b1 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt @@ -147,4 +147,43 @@ class HerdrMultiplexerTest { HerdrMultiplexer.focusWorkspaceCommand("workspace'; no"), ) } + + @Test + fun splitPaneCommandUsesDirectionAndFocus() { + val prelude = "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + + assertEquals( + prelude + "herdr pane split '1-1' --direction right --focus", + HerdrMultiplexer.splitPaneCommand("1-1", HerdrSplitDirection.Right), + ) + assertEquals( + prelude + "herdr pane split '1-1' --direction down --focus", + HerdrMultiplexer.splitPaneCommand("1-1", HerdrSplitDirection.Down), + ) + } + + @Test + fun splitPaneCommandTargetsNamedSessionAndQuotesPaneId() { + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; " + + "herdr --session 'work' pane split 'p'\\''; no' --direction right --focus", + HerdrMultiplexer.splitPaneCommand("p'; no", HerdrSplitDirection.Right, session = "work"), + ) + } + + @Test + fun closePaneCommandQuotesPaneId() { + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; herdr pane close '1-2'", + HerdrMultiplexer.closePaneCommand("1-2"), + ) + } + + @Test + fun closeWorkspaceCommandTargetsNamedSession() { + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; herdr --session 'work' workspace close 'ws-1'", + HerdrMultiplexer.closeWorkspaceCommand("ws-1", session = "work"), + ) + } } From 765c5635983969da3fd6c58303cf1d380483299c Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 18:51:42 +0300 Subject: [PATCH 35/42] feat: create/destroy herdr panes, tabs, workspaces from native UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native mode routes keystrokes to the focused pane's PTY, so herdr's own keybindings for creating/destroying panes never reach the server. Surface these actions explicitly instead: - A compact '+' menu on the tab strip (split right, split down, close pane, new tab) acting on the focused pane/tab. - Long-press a herdr tab to close it. - A close (✕) on each workspace row in the switcher home. Destructive closes go through a confirmation dialog since they terminate any running agent. Built with the app's own Popup + ChuText rows (no material3, which the module doesn't depend on). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSwitcherHome.kt | 11 ++ .../ui/screens/Terminal/TerminalScreen.kt | 73 +++++++++++ .../ui/screens/Terminal/TerminalTabStrip.kt | 117 ++++++++++++++++-- 3 files changed, 189 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt index 986703b6..203e14e4 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -44,6 +44,7 @@ fun HerdrSwitcherHome( onSelectConnection: (String) -> Unit = {}, onOpenServerList: () -> Unit = {}, onCreateWorkspace: () -> Unit = {}, + onCloseWorkspace: (workspaceId: String, label: String) -> Unit = { _, _ -> }, ) { val typography = ChuTypography.current val workspaces = snapshot.workspaces.sortedBy { it.number } @@ -117,6 +118,7 @@ fun HerdrSwitcherHome( expanded = workspace.focused || hasAttention, onEnterWorkspace = onEnterWorkspace, onEnterAgent = onEnterAgent, + onCloseWorkspace = onCloseWorkspace, ) } } @@ -244,6 +246,7 @@ private fun HerdrSwitcherWorkspace( expanded: Boolean, onEnterWorkspace: (String) -> Unit, onEnterAgent: (agentPaneId: String, tabId: String) -> Unit, + onCloseWorkspace: (workspaceId: String, label: String) -> Unit, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -270,6 +273,14 @@ private fun HerdrSwitcherWorkspace( style = typography.labelSmall, color = colors.textMuted, ) + ChuText( + "✕", + style = typography.labelSmall, + color = colors.textMuted, + modifier = Modifier + .clickable { onCloseWorkspace(workspace.workspaceId, label) } + .padding(horizontal = 8.dp, vertical = 2.dp), + ) } if (expanded) { agents.forEach { agent -> diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index 28dc644f..b3d5974d 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -73,6 +73,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.jossephus.chuchu.data.repository.SettingsRepository import com.jossephus.chuchu.model.AuthMethod import com.jossephus.chuchu.model.Transport +import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot +import com.jossephus.chuchu.service.multiplexer.HerdrSplitDirection import com.jossephus.chuchu.service.terminal.SessionStatus import com.jossephus.chuchu.service.terminal.TabSpec import com.jossephus.chuchu.ui.components.ChuButton @@ -350,6 +352,7 @@ fun TerminalScreen( var passphraseInput by remember { mutableStateOf("") } var showCreateWorkspacePrompt by remember { mutableStateOf(false) } var newWorkspaceName by remember { mutableStateOf("") } + var pendingHerdrClose by remember { mutableStateOf(null) } var pendingTabSpec by remember { mutableStateOf(null) } var passphraseFromPicker by remember { mutableStateOf(false) } var showTabSheet by remember { mutableStateOf(false) } @@ -418,6 +421,20 @@ fun TerminalScreen( var herdrSelection by remember(herdrNativeState.focusedPaneId) { mutableStateOf(null) } var herdrSelectionState by remember(herdrNativeState.focusedPaneId) { mutableStateOf(null) } + val herdrFocusedPaneId = herdrNativeState.focusedPaneId ?: herdrNativeState.layout?.focusedPaneId + val onHerdrSplitFocusedPane: (HerdrSplitDirection) -> Unit = { direction -> + herdrFocusedPaneId?.let { vm.onHerdrSplitPane(it, direction) } + } + val onHerdrRequestClosePane: () -> Unit = { + herdrFocusedPaneId?.let { paneId -> + pendingHerdrClose = HerdrCloseTarget( + HerdrCloseKind.Pane, paneId, herdrPaneLabel(herdrNativeState.snapshot, paneId), + ) + } + } + val onHerdrRequestCloseTab: (String, String) -> Unit = { tabId, label -> + pendingHerdrClose = HerdrCloseTarget(HerdrCloseKind.Tab, tabId, label) + } LaunchedEffect(terminalFontSizeSp) { settingsRepo.setTerminalFontSize(terminalFontSizeSp) @@ -661,6 +678,28 @@ fun TerminalScreen( } } + pendingHerdrClose?.let { target -> + ChuDialog( + title = "Close ${target.kind.label} “${target.label}”?", + confirmLabel = "Close", + dismissLabel = "Cancel", + onConfirm = { + when (target.kind) { + HerdrCloseKind.Pane -> vm.onHerdrClosePane(target.id) + HerdrCloseKind.Tab -> vm.onHerdrCloseTab(target.id) + HerdrCloseKind.Workspace -> vm.onHerdrCloseWorkspace(target.id) + } + pendingHerdrClose = null + }, + onDismiss = { pendingHerdrClose = null }, + ) { + ChuText( + "Any agent running in this ${target.kind.label} will be terminated.", + style = typography.body, + ) + } + } + fun showLocalShellFilesUnsupported() { val message = "Files are not supported for local shell" localShellFilesMessage = message @@ -732,6 +771,9 @@ fun TerminalScreen( onHerdrHome = vm::onShowHerdrHome, herdrNativeMode = herdrNativeState.enabled, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), + onHerdrSplitPane = onHerdrSplitFocusedPane, + onHerdrRequestClosePane = onHerdrRequestClosePane, + onHerdrRequestCloseTab = onHerdrRequestCloseTab, ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -823,6 +865,9 @@ fun TerminalScreen( onHerdrHome = vm::onShowHerdrHome, herdrNativeMode = herdrNativeState.enabled, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), + onHerdrSplitPane = onHerdrSplitFocusedPane, + onHerdrRequestClosePane = onHerdrRequestClosePane, + onHerdrRequestCloseTab = onHerdrRequestCloseTab, ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -1149,6 +1194,9 @@ fun TerminalScreen( onHerdrHome = vm::onShowHerdrHome, herdrNativeMode = herdrNativeState.enabled, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), + onHerdrSplitPane = onHerdrSplitFocusedPane, + onHerdrRequestClosePane = onHerdrRequestClosePane, + onHerdrRequestCloseTab = onHerdrRequestCloseTab, ) } @@ -1324,6 +1372,11 @@ fun TerminalScreen( onEnterWorkspace = vm::onEnterHerdrWorkspace, onEnterAgent = vm::onEnterHerdrAgent, onCreateWorkspace = { showCreateWorkspacePrompt = true }, + onCloseWorkspace = { workspaceId, label -> + pendingHerdrClose = HerdrCloseTarget( + HerdrCloseKind.Workspace, workspaceId, label, + ) + }, colors = colors, sessionHint = activeTab?.spec?.tabLabel, connections = tabs, @@ -1900,6 +1953,9 @@ fun TerminalScreen( onHerdrHome = vm::onShowHerdrHome, herdrNativeMode = herdrNativeState.enabled, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), + onHerdrSplitPane = onHerdrSplitFocusedPane, + onHerdrRequestClosePane = onHerdrRequestClosePane, + onHerdrRequestCloseTab = onHerdrRequestCloseTab, ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -2105,3 +2161,20 @@ private fun UploadProgressDialog(progress: UploadProgress) { } } } + +private enum class HerdrCloseKind(val label: String) { + Pane("pane"), + Tab("tab"), + Workspace("workspace"), +} + +private data class HerdrCloseTarget( + val kind: HerdrCloseKind, + val id: String, + val label: String, +) + +private fun herdrPaneLabel(snapshot: HerdrSnapshot?, paneId: String): String { + val agent = snapshot?.agents?.firstOrNull { it.paneId == paneId } + return agent?.agent?.takeIf { it.isNotBlank() } ?: paneId +} diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt index 0f61feca..30360a02 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt @@ -1,10 +1,14 @@ package com.jossephus.chuchu.ui.screens.Terminal +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize @@ -12,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -21,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,9 +36,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import com.jossephus.chuchu.service.multiplexer.HerdrControlState +import com.jossephus.chuchu.service.multiplexer.HerdrSplitDirection import com.jossephus.chuchu.service.terminal.SessionStatus import kotlinx.coroutines.flow.map import kotlin.math.roundToInt @@ -60,6 +70,7 @@ internal fun statusLabel(status: SessionStatus): String = when (status) { * Shows all active terminal sessions in a compact top strip. * The tab list scrolls; primary actions stay pinned on the right. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun TerminalTabStrip( tabs: List, @@ -75,6 +86,9 @@ fun TerminalTabStrip( onHerdrHome: () -> Unit = {}, herdrNativeMode: Boolean = false, hostChipLabel: String? = null, + onHerdrSplitPane: (HerdrSplitDirection) -> Unit = {}, + onHerdrRequestClosePane: () -> Unit = {}, + onHerdrRequestCloseTab: (tabId: String, label: String) -> Unit = { _, _ -> }, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -162,7 +176,14 @@ fun TerminalTabStrip( if (isFocused) colors.accent.copy(alpha = 0.16f) else Color.Transparent ) - .clickable { onHerdrFocusTab(tab.tabId) } + .combinedClickable( + onClick = { onHerdrFocusTab(tab.tabId) }, + onLongClick = if (herdrNativeMode) { + { onHerdrRequestCloseTab(tab.tabId, label) } + } else { + null + }, + ) .padding(horizontal = 8.dp, vertical = 3.dp), contentAlignment = Alignment.Center, ) { @@ -279,18 +300,90 @@ fun TerminalTabStrip( } } - ChuButton( - onClick = { - if (showHerdrTabs) focusedWorkspaceId?.let(onHerdrCreateTab) - else onAddTab() - }, - modifier = Modifier.defaultMinSize(minHeight = 32.dp, minWidth = 32.dp), - variant = ChuButtonVariant.Ghost, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), - contentDescription = if (showHerdrTabs) "new herdr tab" else "new connection", - ) { - ChuText("+", style = typography.label, color = colors.accent) + if (showHerdrTabs && herdrNativeMode) { + var menuExpanded by remember { mutableStateOf(false) } + Box { + ChuButton( + onClick = { menuExpanded = true }, + modifier = Modifier.defaultMinSize(minHeight = 32.dp, minWidth = 32.dp), + variant = ChuButtonVariant.Ghost, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), + contentDescription = "herdr actions", + ) { + ChuText("+", style = typography.label, color = colors.accent) + } + if (menuExpanded) { + Popup( + alignment = Alignment.TopEnd, + onDismissRequest = { menuExpanded = false }, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier + .width(184.dp) + .background(colors.background, RectangleShape) + .border(1.dp, colors.border, RectangleShape) + .padding(vertical = 4.dp), + ) { + HerdrMenuItem("split right", colors.textPrimary) { + menuExpanded = false + onHerdrSplitPane(HerdrSplitDirection.Right) + } + HerdrMenuItem("split down", colors.textPrimary) { + menuExpanded = false + onHerdrSplitPane(HerdrSplitDirection.Down) + } + HerdrMenuItem("close pane", colors.error) { + menuExpanded = false + onHerdrRequestClosePane() + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .height(1.dp) + .background(colors.border.copy(alpha = 0.4f)), + ) + HerdrMenuItem("new tab", colors.textPrimary) { + menuExpanded = false + focusedWorkspaceId?.let(onHerdrCreateTab) + } + } + } + } + } + } else { + ChuButton( + onClick = { + if (showHerdrTabs) focusedWorkspaceId?.let(onHerdrCreateTab) + else onAddTab() + }, + modifier = Modifier.defaultMinSize(minHeight = 32.dp, minWidth = 32.dp), + variant = ChuButtonVariant.Ghost, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), + contentDescription = if (showHerdrTabs) "new herdr tab" else "new connection", + ) { + ChuText("+", style = typography.label, color = colors.accent) + } } } } } + +@Composable +private fun HerdrMenuItem( + label: String, + color: Color, + onClick: () -> Unit, +) { + val typography = ChuTypography.current + ChuText( + text = label, + style = typography.body, + color = color, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) +} From 48a962fe2db2ea211c6e36d2f611715a221a90ca Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 21:02:15 +0300 Subject: [PATCH 36/42] fix: bootstrap single herdr pane at last measured geometry Newly opened pane streams used a hardcoded 80x24 bootstrap until the canvas measured and resized, so switching to a workspace whose tab holds one pane flashed a full-screen TUI (e.g. a coding agent) at the wrong size before the resize round-trip landed. Remember the last real geometry a native canvas reported and reuse it when a single new pane opens (a lone pane fills the whole area, so it is exact); splits keep the safe 80x24 fallback. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../terminal/TerminalSessionRepository.kt | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt index 5afb35b4..e9458ea6 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt @@ -70,6 +70,14 @@ class TerminalSessionRepository private constructor(application: Application) { private val _herdrFocusedPaneId = MutableStateFlow(null) val herdrFocusedPaneId: StateFlow = _herdrFocusedPaneId.asStateFlow() + // Last real pane geometry measured by a native canvas, used to bootstrap + // newly opened pane streams (e.g. on a workspace switch) close to their + // final size so a full-screen TUI doesn't flash at the 80x24 fallback. + @Volatile private var lastHerdrPaneCols = BOOTSTRAP_PANE_COLS + @Volatile private var lastHerdrPaneRows = BOOTSTRAP_PANE_ROWS + @Volatile private var lastHerdrPaneCellW = BOOTSTRAP_CELL_PX + @Volatile private var lastHerdrPaneCellH = BOOTSTRAP_CELL_PX + val activeTab: StateFlow = combine(_tabs, _activeTabId) { tabs, id -> tabs.firstOrNull { it.id == id } } .stateIn(scope, SharingStarted.Eagerly, null) @@ -411,13 +419,21 @@ class TerminalSessionRepository private constructor(application: Application) { setHerdrNativeFocus(snapshot.focusedPaneId) } val existing = tab.engine.herdrPanes.value.keys - desired.filterNot { it in existing }.forEach { paneId -> + val newPanes = desired.filterNot { it in existing } + // A single-pane tab fills the whole area, so the last measured + // geometry is exact; with splits it is an approximation that is + // still far closer than the 80x24 fallback. + val bootstrapCols = if (newPanes.size == 1) lastHerdrPaneCols else BOOTSTRAP_PANE_COLS + val bootstrapRows = if (newPanes.size == 1) lastHerdrPaneRows else BOOTSTRAP_PANE_ROWS + val bootstrapCellW = if (newPanes.size == 1) lastHerdrPaneCellW else BOOTSTRAP_CELL_PX + val bootstrapCellH = if (newPanes.size == 1) lastHerdrPaneCellH else BOOTSTRAP_CELL_PX + newPanes.forEach { paneId -> tab.engine.ensureHerdrPaneStream( paneId = paneId, - cols = BOOTSTRAP_PANE_COLS, - rows = BOOTSTRAP_PANE_ROWS, - cellWidthPx = BOOTSTRAP_CELL_PX, - cellHeightPx = BOOTSTRAP_CELL_PX, + cols = bootstrapCols, + rows = bootstrapRows, + cellWidthPx = bootstrapCellW, + cellHeightPx = bootstrapCellH, focused = paneId == snapshot?.focusedPaneId, ) } @@ -454,6 +470,12 @@ class TerminalSessionRepository private constructor(application: Application) { cellWidth: Int, cellHeight: Int, ) { + if (cols > 0 && rows > 0 && cellWidth > 0 && cellHeight > 0) { + lastHerdrPaneCols = cols + lastHerdrPaneRows = rows + lastHerdrPaneCellW = cellWidth + lastHerdrPaneCellH = cellHeight + } activeEngine()?.ensureHerdrPaneStream( paneId = paneId, cols = cols, From 9b1a03a58388837d69b73fc3d2daf46988dff11c Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 21:23:18 +0300 Subject: [PATCH 37/42] feat: expand herdr workspaces inline to pick a tab or pane Workspace expansion was tied to server focus, so the only way to reveal a workspace's tabs/panes was to enter it (navigating away) and come back. Add a caret toggle that expands a workspace in place: agent-bearing tabs list their panes (tap to jump to that pane) and agent-less tabs show a tab row (tap to focus that tab). Auto-expand for focused/attention workspaces is preserved as the default when the user hasn't toggled. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSwitcherHome.kt | 72 +++++++++++++++++-- .../ui/screens/Terminal/TerminalScreen.kt | 1 + .../ui/screens/Terminal/TerminalViewModel.kt | 5 ++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt index 203e14e4..e5748977 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -24,6 +26,7 @@ import androidx.compose.ui.unit.dp import com.jossephus.chuchu.service.multiplexer.HerdrAgent import com.jossephus.chuchu.service.multiplexer.HerdrAgentStatus import com.jossephus.chuchu.service.multiplexer.HerdrSnapshot +import com.jossephus.chuchu.service.multiplexer.HerdrTab import com.jossephus.chuchu.service.multiplexer.HerdrWorkspace import com.jossephus.chuchu.service.terminal.TabSession import com.jossephus.chuchu.ui.components.ChuText @@ -45,9 +48,13 @@ fun HerdrSwitcherHome( onOpenServerList: () -> Unit = {}, onCreateWorkspace: () -> Unit = {}, onCloseWorkspace: (workspaceId: String, label: String) -> Unit = { _, _ -> }, + onEnterTab: (tabId: String) -> Unit = {}, ) { val typography = ChuTypography.current val workspaces = snapshot.workspaces.sortedBy { it.number } + // User overrides for inline expand/collapse, keyed by workspace id. Absent = + // fall back to auto-expand (focused or has attention). + val expandOverrides = remember { mutableStateMapOf() } Column(modifier = modifier.fillMaxSize()) { Row( @@ -109,14 +116,21 @@ fun HerdrSwitcherHome( } else { items(workspaces, key = { it.workspaceId }) { workspace -> val workspaceAgents = snapshot.agents.filter { it.workspaceId == workspace.workspaceId } + val workspaceTabs = snapshot.tabs + .filter { it.workspaceId == workspace.workspaceId } + .sortedBy { it.number } val hasAttention = workspaceAgents.any { it.agentStatus == HerdrAgentStatus.Blocked || it.agentStatus == HerdrAgentStatus.Done } + val expanded = expandOverrides[workspace.workspaceId] ?: (workspace.focused || hasAttention) HerdrSwitcherWorkspace( workspace = workspace, + tabs = workspaceTabs, agents = workspaceAgents, - expanded = workspace.focused || hasAttention, + expanded = expanded, + onToggleExpand = { expandOverrides[workspace.workspaceId] = !expanded }, onEnterWorkspace = onEnterWorkspace, + onEnterTab = onEnterTab, onEnterAgent = onEnterAgent, onCloseWorkspace = onCloseWorkspace, ) @@ -242,9 +256,12 @@ private fun HerdrConnections( @Composable private fun HerdrSwitcherWorkspace( workspace: HerdrWorkspace, + tabs: List, agents: List, expanded: Boolean, + onToggleExpand: () -> Unit, onEnterWorkspace: (String) -> Unit, + onEnterTab: (String) -> Unit, onEnterAgent: (agentPaneId: String, tabId: String) -> Unit, onCloseWorkspace: (workspaceId: String, label: String) -> Unit, ) { @@ -262,10 +279,18 @@ private fun HerdrSwitcherWorkspace( if (workspace.focused) colors.accent.copy(alpha = 0.65f) else colors.border.copy(alpha = 0.35f), ) .clickable { onEnterWorkspace(workspace.workspaceId) } - .padding(horizontal = 10.dp, vertical = 8.dp), + .padding(horizontal = 6.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { + ChuText( + if (expanded) "▾" else "▸", + style = typography.labelSmall, + color = colors.textMuted, + modifier = Modifier + .clickable { onToggleExpand() } + .padding(horizontal = 6.dp, vertical = 4.dp), + ) ChuText(label, style = typography.label, color = colors.textPrimary, modifier = Modifier.weight(1f)) HerdrAgentStatusSummary(agents) ChuText( @@ -283,12 +308,51 @@ private fun HerdrSwitcherWorkspace( ) } if (expanded) { - agents.forEach { agent -> + tabs.forEach { tab -> + val tabAgents = agents.filter { it.tabId == tab.tabId } + if (tabAgents.isEmpty()) { + HerdrSwitcherTab(tab, onEnterTab) + } else { + tabAgents.forEach { agent -> HerdrSwitcherAgent(agent, label, onEnterAgent) } + } + } + // Agents whose tab isn't listed in the snapshot (safety net). + agents.filter { agent -> tabs.none { it.tabId == agent.tabId } }.forEach { agent -> HerdrSwitcherAgent(agent, label, onEnterAgent) } } } +@Composable +private fun HerdrSwitcherTab( + tab: HerdrTab, + onEnterTab: (String) -> Unit, +) { + val colors = ChuColors.current + val typography = ChuTypography.current + val label = tab.label?.takeIf { it.isNotBlank() } ?: "tab ${tab.number}" + val statusColor = herdrAgentStatusColor(tab.agentStatus, colors) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp, end = 12.dp, top = 1.dp, bottom = 1.dp) + .background(if (tab.focused) colors.surfaceVariant else Color.Transparent) + .border( + 1.dp, + if (tab.focused) colors.accent.copy(alpha = 0.5f) else colors.border.copy(alpha = 0.2f), + ) + .clickable { onEnterTab(tab.tabId) } + .padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.size(6.dp).background(statusColor)) + ChuText(label, style = typography.body, color = colors.textSecondary, modifier = Modifier.weight(1f)) + ChuText(pluralCount(tab.paneCount, "pane"), style = typography.labelSmall, color = colors.textMuted) + } +} + @Composable private fun HerdrAgentStatusSummary(agents: List) { val colors = ChuColors.current diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index b3d5974d..e06b64cd 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -1377,6 +1377,7 @@ fun TerminalScreen( HerdrCloseKind.Workspace, workspaceId, label, ) }, + onEnterTab = vm::onEnterHerdrTab, colors = colors, sessionHint = activeTab?.spec?.tabLabel, connections = tabs, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index 5100c269..30ec219a 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -517,6 +517,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application runHerdrCommand { it.herdrFocusWorkspace(workspaceId) } } + fun onEnterHerdrTab(tabId: String) { + _herdrHomeVisible.value = false + runHerdrCommand { it.herdrFocusTab(tabId) } + } + fun onEnterHerdrAgent(paneId: String, tabId: String) { val engine = sessionRepository.activeTab.value?.engine ?: return _herdrHomeVisible.value = false From 65601cd63afddac18215b931b72104a6c6e83f57 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 21:29:22 +0300 Subject: [PATCH 38/42] =?UTF-8?q?polish:=20cleaner=20herdr=20split=20panes?= =?UTF-8?q?=20=E2=80=94=20dim=20inactive,=20no=20boxes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-pane rectangular border framed every pane in a heavy box that doubled up at shared edges. Drop borders entirely: separate panes with a faint hairline gutter (2+ panes only) and indicate focus by dimming the inactive panes (iTerm2-style) so the active pane stays full brightness. A single pane keeps its clean full-bleed rendering — no border, gutter, or dim. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD --- .../ui/screens/Terminal/HerdrSplitLayout.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt index 84048aae..50767bc5 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt @@ -1,6 +1,6 @@ package com.jossephus.chuchu.ui.screens.Terminal -import androidx.compose.foundation.border +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -51,7 +51,11 @@ fun HerdrSplitLayout( layout.panes } - BoxWithConstraints(modifier = modifier) { + val multiPane = panes.size > 1 + + BoxWithConstraints( + modifier = if (multiPane) modifier.background(colors.border.copy(alpha = 0.35f)) else modifier, + ) { panes.forEach { layoutPane -> val paneModifier = if (layout.zoomed) { Modifier.fillMaxSize() @@ -68,7 +72,7 @@ fun HerdrSplitLayout( .offset(x = left, y = top) .width((right - left).coerceAtLeast(0.dp)) .height((bottom - top).coerceAtLeast(0.dp)) - .padding(0.5.dp) + .padding(if (multiPane) 0.5.dp else 0.dp) } HerdrSplitPane( paneId = layoutPane.paneId, @@ -110,9 +114,7 @@ private fun HerdrSplitPane( modifier: Modifier, ) { val typography = ChuTypography.current - val borderColor = if (focused) colors.accent else colors.border.copy(alpha = 0.55f) - val borderWidth = if (focused) 2.dp else 1.dp - Box(modifier = modifier.border(borderWidth, borderColor)) { + Box(modifier = modifier) { val snapshot = paneState?.snapshot if (snapshot == null) { val message = if (paneState?.status == HerdrPaneStreamStatus.Error) { @@ -159,7 +161,10 @@ private fun HerdrSplitPane( } if (!focused) { Box( - modifier = Modifier.fillMaxSize().clickable { onPaneTap(paneId) }, + modifier = Modifier + .fillMaxSize() + .background(colors.background.copy(alpha = 0.4f)) + .clickable { onPaneTap(paneId) }, ) } if (paneState?.readOnly == true && focused) { From 4758175a2cd1537e485902ba5609b7e9ef1f116b Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 22:24:56 +0300 Subject: [PATCH 39/42] feat: pinch zoom in herdr panes + smooth stepped font zoom Wire onFontSizeChange into HerdrSplitLayout so pinch-to-zoom works in focused herdr native panes (was a dead onZoom = {}), and include the stepped-zoom canvas rework: anchored whole-sp steps with haptic ticks, pointerInput(Unit) + rememberUpdatedState holders so committed steps no longer restart the gesture mid-pinch, and the absolute onFontSizeChange API with minFontSizeSp/maxFontSizeSp bounds. The canvas/call-site portion duplicates upstream PR #70; once that merges, rebase drops it here and keeps only the herdr wiring. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01634UryFkSv94cq7S4WD6vq --- .../ui/screens/Terminal/CommandPalette.kt | 2 +- .../ui/screens/Terminal/HerdrSplitLayout.kt | 8 +- .../ui/screens/Terminal/TerminalScreen.kt | 12 +- .../chuchu/ui/terminal/TerminalCanvas.kt | 114 +++++++++++++----- 4 files changed, 93 insertions(+), 43 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt index 67f208b7..3a153d30 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/CommandPalette.kt @@ -221,7 +221,7 @@ fun CommandPalette( onTap = {}, onPrimaryClick = { _, _ -> }, onScroll = { _, _, _ -> }, - onZoom = {}, + onFontSizeChange = {}, onSelectionChanged = {}, modifier = Modifier.fillMaxSize(), ) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt index 50767bc5..3a85a7ee 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.jossephus.chuchu.data.repository.SettingsRepository import com.jossephus.chuchu.service.terminal.HerdrPaneStreamStatus import com.jossephus.chuchu.ui.components.ChuButton import com.jossephus.chuchu.ui.components.ChuButtonVariant @@ -39,6 +40,7 @@ fun HerdrSplitLayout( onPaneTap: (String) -> Unit, onPaneViewport: (String, Int, Int, Int, Int, Int, Int) -> Unit, onPaneScroll: (String, Int) -> Unit, + onFontSizeChange: (Float) -> Unit, onTakeover: () -> Unit, requestInputFocus: () -> Unit, modifier: Modifier = Modifier, @@ -87,6 +89,7 @@ fun HerdrSplitLayout( onPaneTap = onPaneTap, onPaneViewport = onPaneViewport, onPaneScroll = onPaneScroll, + onFontSizeChange = onFontSizeChange, onTakeover = onTakeover, requestInputFocus = requestInputFocus, modifier = paneModifier, @@ -109,6 +112,7 @@ private fun HerdrSplitPane( onPaneTap: (String) -> Unit, onPaneViewport: (String, Int, Int, Int, Int, Int, Int) -> Unit, onPaneScroll: (String, Int) -> Unit, + onFontSizeChange: (Float) -> Unit, onTakeover: () -> Unit, requestInputFocus: () -> Unit, modifier: Modifier, @@ -135,6 +139,8 @@ private fun HerdrSplitPane( terminalHandle = paneState.handle, enableGestures = focused, fontSizeSp = fontSizeSp, + minFontSizeSp = SettingsRepository.MIN_TERMINAL_FONT_SIZE, + maxFontSizeSp = SettingsRepository.MAX_TERMINAL_FONT_SIZE, cursorColor = ghosttyTheme?.cursorColor ?: Color.White.copy(alpha = 0.28f), cursorTextColor = ghosttyTheme?.cursorText, selectionBackgroundColor = ghosttyTheme?.selectionBackground ?: colors.accent.copy(alpha = 0.45f), @@ -154,7 +160,7 @@ private fun HerdrSplitPane( onPrimaryClick = { _, _ -> }, onAppSelectionDrag = { _, _, _ -> }, onScroll = { delta, _, _ -> onPaneScroll(paneId, delta) }, - onZoom = {}, + onFontSizeChange = onFontSizeChange, onSelectionChanged = if (focused) onSelectionChanged else { _: TerminalSelectionState? -> }, modifier = Modifier.fillMaxSize(), ) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index e06b64cd..7fc98394 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -1401,6 +1401,7 @@ fun TerminalScreen( onPaneTap = vm::onHerdrPaneTap, onPaneViewport = vm::onHerdrPaneViewport, onPaneScroll = vm::onHerdrPaneScroll, + onFontSizeChange = { sizeSp -> terminalFontSizeSp = sizeSp }, onTakeover = vm::onHerdrTakeover, requestInputFocus = requestInputFocus, modifier = Modifier.fillMaxSize(), @@ -1421,6 +1422,8 @@ fun TerminalScreen( TerminalCanvas( snapshot = terminalSnapshot, fontSizeSp = terminalFontSizeSp, + minFontSizeSp = SettingsRepository.MIN_TERMINAL_FONT_SIZE, + maxFontSizeSp = SettingsRepository.MAX_TERMINAL_FONT_SIZE, cursorColor = ghosttyTheme?.cursorColor ?: Color.White.copy(alpha = 0.28f), @@ -1439,14 +1442,7 @@ fun TerminalScreen( onPrimaryClick = vm::onPrimaryMouseClick, onAppSelectionDrag = vm::onAppSelectionDrag, onScroll = vm::onScroll, - onZoom = { zoomFactor -> - terminalFontSizeSp = - (terminalFontSizeSp * zoomFactor) - .coerceIn( - SettingsRepository.MIN_TERMINAL_FONT_SIZE, - SettingsRepository.MAX_TERMINAL_FONT_SIZE, - ) - }, + onFontSizeChange = { sizeSp -> terminalFontSizeSp = sizeSp }, onSelectionChanged = { state -> selectionState = state }, ) } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt index 0d6f1cc9..5019fc4d 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt @@ -45,6 +45,7 @@ import kotlin.math.ceil import kotlin.math.floor import kotlin.math.hypot import kotlin.math.max +import kotlin.math.round import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.isActive import kotlinx.coroutines.withTimeoutOrNull @@ -71,7 +72,9 @@ fun TerminalCanvas( onPrimaryClick: (x: Float, y: Float) -> Unit = { _, _ -> }, onAppSelectionDrag: (action: Int, x: Float, y: Float) -> Unit = { _, _, _ -> }, onScroll: (delta: Int, x: Float, y: Float) -> Unit = { _, _, _ -> }, - onZoom: (zoomFactor: Float) -> Unit = {}, + minFontSizeSp: Float = 1f, + maxFontSizeSp: Float = Float.MAX_VALUE, + onFontSizeChange: (sizeSp: Float) -> Unit = {}, onSelectionChanged: (TerminalSelectionState?) -> Unit = {}, ) { val context = LocalContext.current @@ -180,7 +183,20 @@ fun TerminalCanvas( val currentOnPrimaryClick = rememberUpdatedState(onPrimaryClick) val currentOnAppSelectionDrag = rememberUpdatedState(onAppSelectionDrag) val currentOnScroll = rememberUpdatedState(onScroll) - val currentOnZoom = rememberUpdatedState(onZoom) + val currentOnFontSizeChange = rememberUpdatedState(onFontSizeChange) + val currentFontSizeSp = rememberUpdatedState(fontSizeSp) + val currentMinFontSizeSp = rememberUpdatedState(minFontSizeSp) + val currentMaxFontSizeSp = rememberUpdatedState(maxFontSizeSp) + val currentCellWidth = rememberUpdatedState(cellWidth) + val currentCellHeight = rememberUpdatedState(cellHeight) + val currentFitSnapshotToCanvas = rememberUpdatedState(fitSnapshotToCanvas) + val currentHaptics = rememberUpdatedState(haptics) + val currentTouchSlopPx = rememberUpdatedState(touchSlopPx) + val currentLongPressSlopPx = rememberUpdatedState(longPressSlopPx) + val currentLongPressTimeoutMillis = rememberUpdatedState(longPressTimeoutMillis) + val currentAutoScrollEdgeZonePx = rememberUpdatedState(autoScrollEdgeZonePx) + val currentDoubleTapTimeoutMillis = rememberUpdatedState(doubleTapTimeoutMillis) + val currentDoubleTapSlopPx = rememberUpdatedState(doubleTapSlopPx) val ghosttyBridge = remember { GhosttyBridge() } var selectionViewportBaseline by remember { mutableStateOf(null) } @@ -261,15 +277,15 @@ fun TerminalCanvas( if (!enableGestures) { baseModifier } else { - baseModifier.pointerInput(cellWidth, cellHeight) { + baseModifier.pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) fun toSnapshotSpace(position: Offset, s: TerminalSnapshot): Offset { - if (!fitSnapshotToCanvas) return position + if (!currentFitSnapshotToCanvas.value) return position val cols = max(s.cols, 1) val rows = max(s.rows, 1) - val contentWidth = cols * cellWidth - val contentHeight = rows * cellHeight + val contentWidth = cols * currentCellWidth.value + val contentHeight = rows * currentCellHeight.value if (contentWidth <= 0f || contentHeight <= 0f) return position val scale = minOf(canvasSize.width / contentWidth, canvasSize.height / contentHeight) if (scale <= 0f) return position @@ -278,7 +294,9 @@ fun TerminalCanvas( return Offset((position.x - offsetX) / scale, (position.y - offsetY) / scale) } var dragRemainder = 0f - var lastPinchDistance: Float? = null + var startPinchDistance: Float? = null + var anchorFontSp = 0f + var lastSentSp = 0f var didScroll = false var didPinch = false var didDragGesture = false @@ -286,7 +304,7 @@ fun TerminalCanvas( var lastSinglePointerId = down.id var dragMode = DragMode.None var lastEventUptime = down.uptimeMillis - val longPressDeadline = down.uptimeMillis + longPressTimeoutMillis + val longPressDeadline = down.uptimeMillis + currentLongPressTimeoutMillis.value var lastPointerPos = down.position var autoScrollDir = 0 @@ -307,29 +325,33 @@ fun TerminalCanvas( val downPos = toSnapshotSpace(down.position, s) if (s.appHandlesSelectionDrag) { currentOnAppSelectionDrag.value(TerminalMouseAction.Press, downPos.x, downPos.y) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) + currentHaptics.value.performHapticFeedback( + HapticFeedbackType.LongPress, + ) didDragGesture = true dragMode = DragMode.AppMouseDrag } else if (startSelection( s, downPos, - cellWidth, - cellHeight, + currentCellWidth.value, + currentCellHeight.value, currentOnSelectionChange.value, ) ) { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) + currentHaptics.value.performHapticFeedback( + HapticFeedbackType.LongPress, + ) dragMode = DragMode.ClientSelectionDrag } continue } val pos = lastPointerPos val depth = if (autoScrollDir > 0) { - pos.y - (canvasSize.height - autoScrollEdgeZonePx) + pos.y - (canvasSize.height - currentAutoScrollEdgeZonePx.value) } else { - autoScrollEdgeZonePx - pos.y + currentAutoScrollEdgeZonePx.value - pos.y }.coerceAtLeast(0f) - val rows = (depth / cellHeight).toInt().coerceIn(1, 8) + val rows = (depth / currentCellHeight.value).toInt().coerceIn(1, 8) scrollDeltaChannel.trySend( TerminalScrollDelta(autoScrollDir * rows, pos.x, pos.y), ) @@ -363,13 +385,22 @@ fun TerminalCanvas( doubleTapState.lastTime = tapTime doubleTapState.lastPos = tapPos - if (timeSinceLastTap < doubleTapTimeoutMillis && distSinceLastTap < doubleTapSlopPx) { - val cellIdx = s.cellAt(tapPos.x, tapPos.y, cellWidth, cellHeight) + if (timeSinceLastTap < currentDoubleTapTimeoutMillis.value && + distSinceLastTap < currentDoubleTapSlopPx.value + ) { + val cellIdx = s.cellAt( + tapPos.x, + tapPos.y, + currentCellWidth.value, + currentCellHeight.value, + ) if (cellIdx != null) { val wordRange = s.wordAt(cellIdx) if (wordRange != null) { currentOnSelectionChange.value(TerminalSelection(wordRange.first, wordRange.last)) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) + currentHaptics.value.performHapticFeedback( + HapticFeedbackType.LongPress, + ) } } } else { @@ -386,28 +417,36 @@ fun TerminalCanvas( if (pressed.size >= 2) { didPinch = true - val s = currentSnapshot.value - val first = toSnapshotSpace(pressed[0].position, s) - val second = toSnapshotSpace(pressed[1].position, s) + val first = pressed[0].position + val second = pressed[1].position val distance = hypot( (first.x - second.x).toDouble(), (first.y - second.y).toDouble(), ).toFloat() - val previous = lastPinchDistance - if (previous != null && previous > 0f && distance > 0f) { - val zoomFactor = distance / previous - if (abs(zoomFactor - 1f) > 0.02f) { - currentOnZoom.value(zoomFactor) + if (startPinchDistance == null && distance > 0f) { + startPinchDistance = distance + anchorFontSp = currentFontSizeSp.value + lastSentSp = round(anchorFontSp) + } + val startDistance = startPinchDistance + if (startDistance != null && distance > 0f) { + val steppedSp = round(anchorFontSp * (distance / startDistance)) + .coerceIn(currentMinFontSizeSp.value, currentMaxFontSizeSp.value) + if (steppedSp != lastSentSp) { + currentOnFontSizeChange.value(steppedSp) + currentHaptics.value.performHapticFeedback( + HapticFeedbackType.TextHandleMove, + ) + lastSentSp = steppedSp } } - lastPinchDistance = distance pressed.forEach { change -> if (change.position != change.previousPosition) change.consume() } continue } - lastPinchDistance = null + startPinchDistance = null val change = pressed.firstOrNull { it.id == lastSinglePointerId } ?: pressed.first().also { lastSinglePointerId = it.id } @@ -417,7 +456,12 @@ fun TerminalCanvas( val changePos = toSnapshotSpace(change.position, s) val changePrevPos = toSnapshotSpace(change.previousPosition, s) val downPos = toSnapshotSpace(down.position, s) - val selectedCell = s.cellAt(changePos.x, changePos.y, cellWidth, cellHeight) + val selectedCell = s.cellAt( + changePos.x, + changePos.y, + currentCellWidth.value, + currentCellHeight.value, + ) if (dragMode == DragMode.AppMouseDrag) { lastPointerPos = change.position autoScrollDir = 0 @@ -436,8 +480,8 @@ fun TerminalCanvas( onSelectionChange = currentOnSelectionChange.value, ) autoScrollDir = when { - change.position.y < autoScrollEdgeZonePx -> -1 - change.position.y > canvasSize.height - autoScrollEdgeZonePx -> 1 + change.position.y < currentAutoScrollEdgeZonePx.value -> -1 + change.position.y > canvasSize.height - currentAutoScrollEdgeZonePx.value -> 1 else -> 0 } autoScrollingSelection = autoScrollDir != 0 @@ -453,7 +497,11 @@ fun TerminalCanvas( (changePos.x - downPos.x).toDouble(), (changePos.y - downPos.y).toDouble(), ).toFloat() - val abortSlopPx = if (dragMode != DragMode.None) touchSlopPx else longPressSlopPx + val abortSlopPx = if (dragMode != DragMode.None) { + currentTouchSlopPx.value + } else { + currentLongPressSlopPx.value + } if (movedDistance > abortSlopPx) { didDragGesture = true if (currentSelectionState.value != null && !selectionCleared) { @@ -465,7 +513,7 @@ fun TerminalCanvas( autoScrollingSelection = false val verticalIntent = abs(dragY) > abs(dragX) * 1.2f if (verticalIntent) { - dragRemainder += dragY / cellHeight + dragRemainder += dragY / currentCellHeight.value } if (didDragGesture && abs(dragRemainder) >= 1f) { From 6c44a5abd9c73c08c108aa3b47ad461d1aa37db8 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 22:51:49 +0300 Subject: [PATCH 40/42] feat: show focused workspace name in herdr tab strip chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leading chip in native mode was a static "⌂ herdr", so there was no way to tell which workspace you were in without opening the switcher. Label it with the focused workspace instead — label, falling back to "ws ", then "herdr" when the snapshot has no match. Tap behavior (open switcher home) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01634UryFkSv94cq7S4WD6vq --- .../chuchu/ui/screens/Terminal/TerminalTabStrip.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt index 30360a02..55fce270 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt @@ -98,6 +98,8 @@ fun TerminalTabStrip( val rowRootLeft = remember { mutableStateOf(0) } val herdrSnapshot = (herdrState as? HerdrControlState.Active)?.snapshot val focusedWorkspaceId = herdrSnapshot?.focusedWorkspaceId + val focusedWorkspace = + herdrSnapshot?.workspaces?.firstOrNull { it.workspaceId == focusedWorkspaceId } val herdrTabs = herdrSnapshot ?.tabs @@ -126,7 +128,13 @@ fun TerminalTabStrip( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { val hostLabel = - if (herdrNativeMode) "⌂ herdr" + if (herdrNativeMode) { + val workspaceLabel = + focusedWorkspace?.label?.takeIf { it.isNotBlank() } + ?: focusedWorkspace?.let { "ws ${it.number}" } + ?: "herdr" + "⌂ $workspaceLabel" + } else hostChipLabel ?: tabs.firstOrNull { it.id == activeTabId }?.let(::terminalTabDisplayLabel) ?: "terminal" From a97e0ac0212e42810c829b85c390a975bf81ff71 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 23:46:25 +0300 Subject: [PATCH 41/42] =?UTF-8?q?feat:=20near-instant=20herdr=20tab=20swit?= =?UTF-8?q?ching=20=E2=80=94=20warm=20pane=20streams=20+=20optimistic=20fo?= =?UTF-8?q?cus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab switching in native mode took ~1.2s measured on-device: ~185ms for the herdr focus CLI exec, ~250ms for the next control snapshot poll, and ~790ms re-opening pane streams, because pane hosts were disposed the instant a tab lost focus. Two changes, benchmarked at each step: - Warm pane streams: desiredHerdrPaneStreams keeps the panes of up to two recently focused tabs alive alongside the focused tab's (12-stream cap, zoomed recent tabs contribute only their focused pane). The repository tracks per-connection recency; everything still drops when the app is backgrounded. Switch-back cost fell from ~1.2s to ~375ms, with content already live on arrival. - Optimistic focus: tapping a tab immediately swaps the layout, strip highlight, pane focus, and stream policy to the target using the layout geometry already present in the snapshot, then confirms via the focus command in the background. Failure or a 4s reconciliation timeout snaps back to snapshot truth; switching to another chuchu connection clears the override so it can never bleed across sessions. Warm switches now land in the same frame as the tap (~0ms perceived); cold tabs start their streams at tap time behind an instant layout swap. Adds policy unit coverage for recency, caps, override, and background gating in HerdrPaneStreamPolicyTest. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01634UryFkSv94cq7S4WD6vq --- .../service/multiplexer/HerdrSnapshot.kt | 43 ++++- .../terminal/TerminalSessionRepository.kt | 44 ++++- .../ui/screens/Terminal/TerminalScreen.kt | 4 + .../ui/screens/Terminal/TerminalTabStrip.kt | 6 +- .../ui/screens/Terminal/TerminalViewModel.kt | 55 +++++- .../multiplexer/HerdrPaneStreamPolicyTest.kt | 181 +++++++++++++++++- 6 files changed, 317 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt index 32109185..23acfd95 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt @@ -52,14 +52,53 @@ data class HerdrTabLayout( val zoomed: Boolean = false, ) +const val MAX_WARM_PANE_STREAMS = 12 +const val MAX_RECENT_TABS = 2 + fun desiredHerdrPaneStreams( snapshot: HerdrSnapshot?, nativeModeActive: Boolean, foreground: Boolean, + recentTabIds: List = emptyList(), + focusedTabIdOverride: String? = null, ): Set { if (!nativeModeActive || !foreground || snapshot == null) return emptySet() - val layout = snapshot.layouts.firstOrNull { it.tabId == snapshot.focusedTabId } ?: return emptySet() - val focusedPaneId = snapshot.focusedPaneId ?: layout.focusedPaneId + + val focusedTabId = focusedTabIdOverride ?: snapshot.focusedTabId + val focused = desiredFocusedHerdrPaneStreams(snapshot, focusedTabId) + val desired = LinkedHashSet(MAX_WARM_PANE_STREAMS) + desired.addAll(focused) + + recentTabIds + .asSequence() + .filterNot { it == focusedTabId } + .forEach { tabId -> + val layout = snapshot.layouts.firstOrNull { it.tabId == tabId } ?: return@forEach + val paneIds = + if (layout.zoomed) { + listOfNotNull(layout.focusedPaneId?.takeIf { it.isNotBlank() }) + } else { + layout.panes.map { it.paneId }.filter { it.isNotBlank() } + } + paneIds.forEach { paneId -> + if (desired.size < MAX_WARM_PANE_STREAMS) desired.add(paneId) + } + } + + return desired +} + +private fun desiredFocusedHerdrPaneStreams( + snapshot: HerdrSnapshot, + focusedTabId: String?, +): Set { + val layout = snapshot.layouts.firstOrNull { it.tabId == focusedTabId } ?: return emptySet() + val focusedPaneId = + if (focusedTabId == snapshot.focusedTabId) { + snapshot.focusedPaneId ?: layout.focusedPaneId + } else { + layout.focusedPaneId + } if (layout.zoomed) return focusedPaneId?.let(::setOf) ?: emptySet() val paneIds = layout.panes.map { it.paneId }.filter { it.isNotBlank() }.toMutableSet() if (paneIds.size <= 6) return paneIds diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt index e9458ea6..8e892c77 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt @@ -7,6 +7,7 @@ import com.jossephus.chuchu.data.repository.SettingsRepository import com.jossephus.chuchu.model.MultiplexerType import com.jossephus.chuchu.model.Transport import com.jossephus.chuchu.service.multiplexer.HerdrControlState +import com.jossephus.chuchu.service.multiplexer.MAX_RECENT_TABS import com.jossephus.chuchu.service.multiplexer.MultiplexerRegistry import com.jossephus.chuchu.service.multiplexer.RemoteMultiplexerSession import com.jossephus.chuchu.service.multiplexer.desiredHerdrPaneStreams @@ -50,6 +51,9 @@ class TerminalSessionRepository private constructor(application: Application) { private val herdrAgentNotifier = HerdrAgentNotifier(appContext) private val herdrStateJobs = mutableMapOf() private val herdrCadenceByTab = mutableMapOf() + private val herdrRecentTabsByTab = mutableMapOf>() + private val herdrOptimisticFocusLock = Any() + @Volatile private var herdrOptimisticFocusTabId: String? = null private fun publishTerminalClipboard(tabId: String, text: String) { if (!canPublishTerminalClipboard(tabId)) return @@ -290,6 +294,7 @@ class TerminalSessionRepository private constructor(application: Application) { val tab = _tabs.value.firstOrNull { it.id == id } ?: return herdrStateJobs.remove(id)?.cancel() herdrCadenceByTab.remove(id) + herdrRecentTabsByTab.remove(id) herdrAgentNotifier.removeTab(id) val remaining = _tabs.value.filterNot { it.id == id } _tabs.value = remaining @@ -354,6 +359,7 @@ class TerminalSessionRepository private constructor(application: Application) { herdrStateJobs.values.forEach(Job::cancel) herdrStateJobs.clear() herdrCadenceByTab.clear() + herdrRecentTabsByTab.clear() _herdrFocusedPaneId.value = null tabs.forEach { herdrAgentNotifier.removeTab(it.id) } tabs.forEach { it.engine.dispose() } @@ -408,14 +414,30 @@ class TerminalSessionRepository private constructor(application: Application) { _tabs.value.forEach { tab -> if (tab.id == activeTab?.id && tab.spec.usesHerdrNativeMode) { val snapshot = (tab.engine.herdrState.value as? HerdrControlState.Active)?.snapshot + val focusedTabIdOverride = herdrOptimisticFocusTabId + val effectiveFocusedTabId = focusedTabIdOverride ?: snapshot?.focusedTabId + val recentTabIds = + effectiveFocusedTabId?.let { focusedTabId -> + herdrRecentTabsByTab.getOrPut(tab.id) { ArrayDeque() }.apply { + remove(focusedTabId) + addFirst(focusedTabId) + while (size > MAX_RECENT_TABS + 1) removeLast() + }.toList() + } ?: emptyList() val desired = desiredHerdrPaneStreams( snapshot = snapshot, nativeModeActive = true, foreground = attachedClients > 0, + recentTabIds = recentTabIds, + focusedTabIdOverride = focusedTabIdOverride, ) tab.engine.retainHerdrPaneStreams(desired) - if (snapshot != null && _herdrFocusedPaneId.value != snapshot.focusedPaneId) { + if ( + snapshot != null && + (focusedTabIdOverride == null || focusedTabIdOverride == snapshot.focusedTabId) && + _herdrFocusedPaneId.value != snapshot.focusedPaneId + ) { setHerdrNativeFocus(snapshot.focusedPaneId) } val existing = tab.engine.herdrPanes.value.keys @@ -459,6 +481,26 @@ class TerminalSessionRepository private constructor(application: Application) { activeEngine()?.setHerdrFocusedPaneStream(paneId) } + fun setHerdrOptimisticFocusTab(tabId: String?) { + synchronized(herdrOptimisticFocusLock) { + herdrOptimisticFocusTabId = tabId + } + updateHerdrPaneStreams() + } + + fun clearHerdrOptimisticFocusTab(tabId: String) { + val cleared = + synchronized(herdrOptimisticFocusLock) { + if (herdrOptimisticFocusTabId != tabId) { + false + } else { + herdrOptimisticFocusTabId = null + true + } + } + if (cleared) updateHerdrPaneStreams() + } + fun herdrTakeover() { activeEngine()?.herdrTakeoverFocusedPane() } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt index 7fc98394..95056bb2 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalScreen.kt @@ -770,6 +770,7 @@ fun TerminalScreen( onHerdrCreateTab = vm::onHerdrCreateTab, onHerdrHome = vm::onShowHerdrHome, herdrNativeMode = herdrNativeState.enabled, + herdrFocusedTabId = herdrNativeState.focusedTabId, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), onHerdrSplitPane = onHerdrSplitFocusedPane, onHerdrRequestClosePane = onHerdrRequestClosePane, @@ -864,6 +865,7 @@ fun TerminalScreen( onHerdrCreateTab = vm::onHerdrCreateTab, onHerdrHome = vm::onShowHerdrHome, herdrNativeMode = herdrNativeState.enabled, + herdrFocusedTabId = herdrNativeState.focusedTabId, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), onHerdrSplitPane = onHerdrSplitFocusedPane, onHerdrRequestClosePane = onHerdrRequestClosePane, @@ -1193,6 +1195,7 @@ fun TerminalScreen( onHerdrCreateTab = vm::onHerdrCreateTab, onHerdrHome = vm::onShowHerdrHome, herdrNativeMode = herdrNativeState.enabled, + herdrFocusedTabId = herdrNativeState.focusedTabId, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), onHerdrSplitPane = onHerdrSplitFocusedPane, onHerdrRequestClosePane = onHerdrRequestClosePane, @@ -1949,6 +1952,7 @@ fun TerminalScreen( onHerdrCreateTab = vm::onHerdrCreateTab, onHerdrHome = vm::onShowHerdrHome, herdrNativeMode = herdrNativeState.enabled, + herdrFocusedTabId = herdrNativeState.focusedTabId, hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), onHerdrSplitPane = onHerdrSplitFocusedPane, onHerdrRequestClosePane = onHerdrRequestClosePane, diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt index 55fce270..476b0190 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt @@ -85,6 +85,7 @@ fun TerminalTabStrip( onHerdrCreateTab: (String) -> Unit = {}, onHerdrHome: () -> Unit = {}, herdrNativeMode: Boolean = false, + herdrFocusedTabId: String? = null, hostChipLabel: String? = null, onHerdrSplitPane: (HerdrSplitDirection) -> Unit = {}, onHerdrRequestClosePane: () -> Unit = {}, @@ -107,7 +108,8 @@ fun TerminalTabStrip( ?.sortedBy { it.number } .orEmpty() val showHerdrTabs = herdrEnabled && herdrSnapshot != null && herdrTabs.isNotEmpty() - val focusedHerdrTabId = herdrTabs.firstOrNull { it.focused }?.tabId ?: herdrSnapshot?.focusedTabId + val focusedHerdrTabId = + herdrFocusedTabId ?: herdrTabs.firstOrNull { it.focused }?.tabId ?: herdrSnapshot?.focusedTabId val scrollTargetId = if (showHerdrTabs) focusedHerdrTabId else activeTabId LaunchedEffect(scrollTargetId) { @@ -167,7 +169,7 @@ fun TerminalTabStrip( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { herdrTabs.forEach { tab -> - val isFocused = tab.focused + val isFocused = herdrFocusedTabId?.let { tab.tabId == it } ?: tab.focused val label = tab.label?.takeIf { it.isNotBlank() } ?: "tab ${tab.number}" val statusColor = herdrAgentStatusColor(tab.agentStatus, colors) diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt index 30ec219a..7b68a8a5 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalViewModel.kt @@ -51,14 +51,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull @OptIn(ExperimentalCoroutinesApi::class) class TerminalViewModel(application: Application) : AndroidViewModel(application) { @@ -81,6 +84,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private var multiplexerSessionListGeneration = 0L private val _herdrActionError = MutableStateFlow(null) private val _herdrHomeVisible = MutableStateFlow(true) + private val _herdrOptimisticTabId = MutableStateFlow(null) val herdrHomeVisible: StateFlow = _herdrHomeVisible.asStateFlow() private val _tailscaleActive = MutableStateFlow(tailscaleStatusChecker.isActive()) @@ -112,14 +116,17 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application tab.engine.herdrPaneStates, sessionRepository.herdrFocusedPaneId, herdrHomeVisible, - ) { control, panes, focusedPaneId, homeVisible -> + _herdrOptimisticTabId, + ) { control, panes, focusedPaneId, homeVisible, optimisticTabId -> val snapshot = (control as? HerdrControlState.Active)?.snapshot + val effectiveTabId = optimisticTabId ?: snapshot?.focusedTabId HerdrNativeUiState( enabled = true, snapshot = snapshot, - layout = snapshot?.layouts?.firstOrNull { it.tabId == snapshot.focusedTabId }, + layout = snapshot?.layouts?.firstOrNull { it.tabId == effectiveTabId }, panes = panes, focusedPaneId = focusedPaneId, + focusedTabId = effectiveTabId, homeVisible = homeVisible, ) } @@ -141,6 +148,13 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application if (tab?.spec?.usesHerdrNativeMode == true) _herdrHomeVisible.value = true } } + viewModelScope.launch { + activeTabId.drop(1).collect { + val optimisticTabId = _herdrOptimisticTabId.value ?: return@collect + _herdrOptimisticTabId.value = null + sessionRepository.clearHerdrOptimisticFocusTab(optimisticTabId) + } + } } private val _connectionTabByTab = MutableStateFlow>(emptyMap()) @@ -471,7 +485,35 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application fun onHerdrFocusTab(tabId: String) { showHerdrSplitsIfNative() - runHerdrCommand { it.herdrFocusTab(tabId) } + val engine = sessionRepository.activeTab.value?.engine ?: return + val snapshot = (engine.herdrState.value as? HerdrControlState.Active)?.snapshot + val layout = snapshot?.layouts?.firstOrNull { it.tabId == tabId } + if (layout == null) { + runHerdrCommand { it.herdrFocusTab(tabId) } + return + } + + _herdrOptimisticTabId.value = tabId + sessionRepository.setHerdrOptimisticFocusTab(tabId) + layout.focusedPaneId?.let(sessionRepository::setHerdrNativeFocus) + viewModelScope.launch { + val result = + runCatching { engine.herdrFocusTab(tabId) }.getOrElse { error -> + MultiplexerCommandResult(1, "", error.message ?: "Herdr command failed") + } + updateHerdrActionError(result) + if (result.exitCode != 0) { + clearHerdrOptimisticTab(tabId) + return@launch + } + + withTimeoutOrNull(4_000) { + engine.herdrState.first { control -> + (control as? HerdrControlState.Active)?.snapshot?.focusedTabId == tabId + } + } + clearHerdrOptimisticTab(tabId) + } } fun onHerdrFocusPane(paneId: String) { @@ -579,6 +621,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application } } + private fun clearHerdrOptimisticTab(tabId: String) { + if (_herdrOptimisticTabId.compareAndSet(tabId, null)) { + sessionRepository.clearHerdrOptimisticFocusTab(tabId) + } + } + private fun updateHerdrActionError(result: MultiplexerCommandResult) { _herdrActionError.value = if (result.exitCode == 0) null @@ -942,5 +990,6 @@ data class HerdrNativeUiState( val layout: HerdrTabLayout? = null, val panes: Map = emptyMap(), val focusedPaneId: String? = null, + val focusedTabId: String? = null, val homeVisible: Boolean = true, ) diff --git a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt index 21a58d9c..9c144e41 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt @@ -41,21 +41,186 @@ class HerdrPaneStreamPolicyTest { assertEquals(linkedSetOf("p8", "p1", "p2", "p3", "p4", "p5"), desired) } + @Test + fun includesRecentTabPanesAfterFocusedTabPanes() { + val snapshot = + snapshot( + panes = listOf("p1", "p2"), + additionalLayouts = listOf(layout("recent", listOf("r1", "r2"))), + ) + + assertEquals( + linkedSetOf("p1", "p2", "r1", "r2"), + desiredHerdrPaneStreams(snapshot, true, true, recentTabIds = listOf("recent")), + ) + } + + @Test + fun capsWarmStreamsWithoutEvictingFocusedPanes() { + val focusedPanes = (1..8).map { "p$it" } + val recentPanes = (1..8).map { "r$it" } + val snapshot = + snapshot( + panes = focusedPanes, + focusedPaneId = "p8", + additionalLayouts = listOf(layout("recent", recentPanes)), + ) + + val desired = desiredHerdrPaneStreams(snapshot, true, true, recentTabIds = listOf("recent")) + + assertEquals(12, desired.size) + assertEquals( + linkedSetOf("p8", "p1", "p2", "p3", "p4", "p5", "r1", "r2", "r3", "r4", "r5", "r6"), + desired, + ) + } + + @Test + fun skipsFocusedTabWhenItAppearsInRecentTabs() { + val snapshot = + snapshot( + panes = listOf("p1", "p2"), + additionalLayouts = listOf(layout("recent", listOf("r1"))), + ) + + assertEquals( + linkedSetOf("p1", "p2", "r1"), + desiredHerdrPaneStreams(snapshot, true, true, recentTabIds = listOf("tab", "recent")), + ) + } + + @Test + fun overrideSelectsOverrideTabPanesAsFocused() { + val snapshot = + snapshot( + panes = listOf("p1", "p2"), + additionalLayouts = listOf(layout("override", listOf("o1", "o2"))), + ) + + assertEquals( + linkedSetOf("o1", "o2"), + desiredHerdrPaneStreams( + snapshot, + true, + true, + focusedTabIdOverride = "override", + ), + ) + } + + @Test + fun overrideKeepsPreviousFocusedTabWarm() { + val snapshot = + snapshot( + panes = listOf("p1", "p2"), + additionalLayouts = listOf(layout("override", listOf("o1", "o2"))), + ) + + assertEquals( + linkedSetOf("o1", "o2", "p1", "p2"), + desiredHerdrPaneStreams( + snapshot, + true, + true, + recentTabIds = listOf("override", "tab"), + focusedTabIdOverride = "override", + ), + ) + } + + @Test + fun matchingOverrideBehavesLikeNoOverride() { + val snapshot = + snapshot( + panes = listOf("p1", "p2"), + additionalLayouts = listOf(layout("recent", listOf("r1"))), + ) + val withoutOverride = + desiredHerdrPaneStreams( + snapshot, + true, + true, + recentTabIds = listOf("tab", "recent"), + ) + + assertEquals( + withoutOverride, + desiredHerdrPaneStreams( + snapshot, + true, + true, + recentTabIds = listOf("tab", "recent"), + focusedTabIdOverride = "tab", + ), + ) + } + + @Test + fun returnsEmptyOutsideForegroundWithRecentTabs() { + val snapshot = + snapshot( + panes = listOf("p1"), + additionalLayouts = listOf(layout("recent", listOf("r1"))), + ) + + assertEquals( + emptySet(), + desiredHerdrPaneStreams(snapshot, true, false, recentTabIds = listOf("recent")), + ) + } + + @Test + fun zoomedRecentTabContributesOnlyItsFocusedPane() { + val snapshot = + snapshot( + panes = listOf("p1"), + additionalLayouts = + listOf( + layout( + tabId = "recent", + panes = listOf("r1", "r2"), + focusedPaneId = "r2", + zoomed = true, + ), + ), + ) + + assertEquals( + linkedSetOf("p1", "r2"), + desiredHerdrPaneStreams(snapshot, true, true, recentTabIds = listOf("recent")), + ) + } + private fun snapshot( panes: List, focusedPaneId: String = "p2", zoomed: Boolean = false, + additionalLayouts: List = emptyList(), ): HerdrSnapshot = HerdrSnapshot( focusedTabId = "tab", focusedPaneId = focusedPaneId, - layouts = listOf( - HerdrTabLayout( - tabId = "tab", - focusedPaneId = focusedPaneId, - panes = panes.map { HerdrLayoutPane(paneId = it) }, - zoomed = zoomed, - ), - ), + layouts = + listOf( + layout( + tabId = "tab", + panes = panes, + focusedPaneId = focusedPaneId, + zoomed = zoomed, + ), + ) + additionalLayouts, + ) + + private fun layout( + tabId: String, + panes: List, + focusedPaneId: String = panes.first(), + zoomed: Boolean = false, + ): HerdrTabLayout = + HerdrTabLayout( + tabId = tabId, + focusedPaneId = focusedPaneId, + panes = panes.map { HerdrLayoutPane(paneId = it) }, + zoomed = zoomed, ) } From 1419293ba4ca5df8ba2188df7c33c906cb8ea688 Mon Sep 17 00:00:00 2001 From: Salem Date: Mon, 20 Jul 2026 23:57:25 +0300 Subject: [PATCH 42/42] fix: harden herdr stream lifecycles --- .../chuchu/data/backup/ChuchuBackupModels.kt | 3 + .../service/backup/ChuchuBackupCodec.kt | 5 +- .../chuchu/service/terminal/HerdrPaneHost.kt | 59 ++- .../service/terminal/TerminalSessionEngine.kt | 32 +- .../chuchu/ui/terminal/TerminalCanvas.kt | 399 +++++++++--------- .../data/backup/ChuchuBackupCoreTest.kt | 30 +- 6 files changed, 298 insertions(+), 230 deletions(-) diff --git a/android/app/src/main/java/com/jossephus/chuchu/data/backup/ChuchuBackupModels.kt b/android/app/src/main/java/com/jossephus/chuchu/data/backup/ChuchuBackupModels.kt index 33a7c835..b8027074 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/data/backup/ChuchuBackupModels.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/data/backup/ChuchuBackupModels.kt @@ -64,6 +64,7 @@ data class BackupHostProfile( val requireAuthOnConnect: Boolean, val postConnectCommand: String?, val multiplexer: MultiplexerType?, + val herdrNativeMode: Boolean = true, ) { fun toEntity( idOverride: Long = id, @@ -82,6 +83,7 @@ data class BackupHostProfile( requireAuthOnConnect = requireAuthOnConnect, postConnectCommand = postConnectCommand, multiplexer = multiplexer, + herdrNativeMode = herdrNativeMode, ) companion object { @@ -99,6 +101,7 @@ data class BackupHostProfile( requireAuthOnConnect = host.requireAuthOnConnect, postConnectCommand = host.postConnectCommand, multiplexer = host.multiplexer, + herdrNativeMode = host.herdrNativeMode, ) } } diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/backup/ChuchuBackupCodec.kt b/android/app/src/main/java/com/jossephus/chuchu/service/backup/ChuchuBackupCodec.kt index 16ad2d52..4b1fc81e 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/backup/ChuchuBackupCodec.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/backup/ChuchuBackupCodec.kt @@ -11,7 +11,7 @@ import com.jossephus.chuchu.model.Transport object ChuchuBackupCodec { const val FORMAT_VERSION: Int = 1 - const val PAYLOAD_VERSION: Int = 2 + const val PAYLOAD_VERSION: Int = 3 const val KDF_ID_PBKDF2_HMAC_SHA1: Int = 1 const val CIPHER_ID_AES_256_GCM: Int = 1 const val KDF_ITERATIONS: Int = 210_000 @@ -102,6 +102,7 @@ object ChuchuBackupCodec { writer.writeBoolean(host.requireAuthOnConnect) writeNullableStringField(host.postConnectCommand, "post-connect command") writeNullableStringField(host.multiplexer?.id, "multiplexer") + writer.writeBoolean(host.herdrNativeMode) } val encoded = writer.toByteArray() @@ -173,6 +174,7 @@ object ChuchuBackupCodec { } else { null } + val herdrNativeMode = if (version >= 3) reader.readBoolean() else true if (transport != Transport.LocalShell) { add( BackupHostProfile( @@ -189,6 +191,7 @@ object ChuchuBackupCodec { requireAuthOnConnect = requireAuthOnConnect, postConnectCommand = postConnectCommand, multiplexer = multiplexer, + herdrNativeMode = herdrNativeMode, ), ) } diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt index db5a0faf..6ab6dfed 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -49,7 +50,7 @@ data class HerdrPaneState( class HerdrPaneHost( val paneId: String, - private val connection: SharedSshConnection, + private val connectionProvider: suspend () -> SharedSshConnection, private val runCommand: (HerdrStreamMode, Int, Int) -> String, private val scope: CoroutineScope, ) { @@ -180,11 +181,12 @@ class HerdrPaneHost( fun dispose() { if (disposed) return disposed = true - streamJob?.cancel() + val job = streamJob streamJob = null runBlocking { + job?.cancelAndJoin() withContext(dispatcher) { - closeStreamChannel() + discardPendingCommands() snapshotScheduled = false if (handle != 0L) { bridge.nativeDestroy(handle) @@ -197,9 +199,10 @@ class HerdrPaneHost( } private suspend fun restartStream() { - streamJob?.cancel() + streamJob?.cancelAndJoin() streamJob = null - closeStreamChannel() + streamChannelId = null + discardPendingCommands() _state.value = _state.value.copy( status = HerdrPaneStreamStatus.Connecting, @@ -214,16 +217,18 @@ class HerdrPaneHost( var mode = initialMode var retryDelayMs = 2_000L while (currentCoroutineContext().isActive && !disposed) { + var connection: SharedSshConnection? = null var channelId: Int? = null var failed = false try { setConnecting(mode) val (commandCols, commandRows) = withContext(dispatcher) { cols to rows } + connection = connectionProvider() channelId = connection.openExecChannel(runCommand(mode, commandCols, commandRows)) streamChannelId = channelId retryDelayMs = 2_000L - syncViewport(channelId, commandCols, commandRows) - when (readFrames(channelId, mode)) { + syncViewport(connection, channelId, commandCols, commandRows) + when (readFrames(connection, channelId, mode)) { StreamEnd.Restart -> Unit StreamEnd.ControlRefused -> { mode = HerdrStreamMode.Observe @@ -237,8 +242,12 @@ class HerdrPaneHost( setError(error.message ?: "Herdr terminal stream failed") } finally { if (streamChannelId == channelId) streamChannelId = null - channelId?.let { id -> - withContext(NonCancellable) { runCatching { connection.closeChannel(id) } } + val closingConnection = connection + val closingChannelId = channelId + if (closingConnection != null && closingChannelId != null) { + withContext(NonCancellable) { + runCatching { closingConnection.closeChannel(closingChannelId) } + } } } if (failed && !disposed) { @@ -248,11 +257,15 @@ class HerdrPaneHost( } } - private suspend fun readFrames(channelId: Int, mode: HerdrStreamMode): StreamEnd { + private suspend fun readFrames( + connection: SharedSshConnection, + channelId: Int, + mode: HerdrStreamMode, + ): StreamEnd { val buffer = StringBuilder() var lastSeq: Long? = null while (currentCoroutineContext().isActive && !disposed) { - writePendingCommands(channelId) + writePendingCommands(connection, channelId) val chunk = connection.readChannel(channelId, 8192) if (chunk != null && chunk.isNotEmpty()) { for (line in appendHerdrNdjsonChunk(buffer, chunk.toString(Charsets.UTF_8))) { @@ -286,7 +299,12 @@ class HerdrPaneHost( throw CancellationException() } - private suspend fun syncViewport(channelId: Int, openedCols: Int, openedRows: Int) { + private suspend fun syncViewport( + connection: SharedSshConnection, + channelId: Int, + openedCols: Int, + openedRows: Int, + ) { val (currentCols, currentRows) = withContext(dispatcher) { cols to rows } if (currentCols != openedCols || currentRows != openedRows) { connection.writeChannel( @@ -296,7 +314,10 @@ class HerdrPaneHost( } } - private suspend fun writePendingCommands(channelId: Int) { + private suspend fun writePendingCommands( + connection: SharedSshConnection, + channelId: Int, + ) { while (true) { val pending = pendingCommands.tryReceive().getOrNull() ?: return if (pending.channelId == channelId) { @@ -305,6 +326,12 @@ class HerdrPaneHost( } } + private fun discardPendingCommands() { + while (pendingCommands.tryReceive().isSuccess) { + // Commands belong to the stream generation that captured their channel ID. + } + } + private suspend fun applyFrame(width: Int, height: Int, bytes: ByteArray) { withContext(dispatcher) { if (handle == 0L) return@withContext @@ -361,12 +388,6 @@ class HerdrPaneHost( pendingCommands.trySend(PendingCommand(channelId, json)) } - private suspend fun closeStreamChannel() { - val channelId = streamChannelId ?: return - streamChannelId = null - runCatching { connection.closeChannel(channelId) } - } - private fun requestSnapshot(force: Boolean = false) { if (handle == 0L) return val now = System.currentTimeMillis() diff --git a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt index 2b8a5872..b95c48d0 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionEngine.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -154,7 +155,6 @@ class TerminalSessionEngine( private var herdrConnection: SharedSshConnection? = null private val herdrConnectionMutex = Mutex() private var herdrStreamJob: Job? = null - @Volatile private var herdrStreamService: NativeSshService? = null private val herdrPoke = Channel(Channel.CONFLATED) private val herdrCadenceMs = MutableStateFlow(3_000L) @Volatile private var herdrIdleStretch = 0 @@ -707,7 +707,7 @@ class TerminalSessionEngine( val host = HerdrPaneHost( paneId = paneId, - connection = ensureHerdrConnection(params), + connectionProvider = { ensureHerdrConnection(params) }, runCommand = { mode, commandCols, commandRows -> HerdrMultiplexer.terminalSessionCommand(paneId, commandCols, commandRows, mode, params.multiplexerSessionName) }, @@ -775,11 +775,25 @@ class TerminalSessionEngine( keyPassphrase = params.keyPassphrase, hostKeyPolicy = ::verifyHostKey, ) - connection.connect() - herdrConnection = connection - connection + try { + connection.connect() + herdrConnection = connection + connection + } catch (error: Exception) { + connection.close() + throw error + } } + private suspend fun invalidateHerdrConnection(connection: SharedSshConnection?) { + if (connection == null) return + herdrConnectionMutex.withLock { + if (herdrConnection !== connection) return@withLock + herdrConnection = null + connection.close() + } + } + private fun cancelHostKeyPrompt() { hostKeyDecision?.cancel() hostKeyDecision = null @@ -1209,8 +1223,7 @@ class TerminalSessionEngine( } catch (error: CancellationException) { throw error } catch (error: Exception) { - herdrConnection?.close() - herdrConnection = null + invalidateHerdrConnection(connection) MultiplexerCommandResult(1, "", error.message ?: "Herdr command failed") } pokeHerdr() @@ -1236,7 +1249,6 @@ class TerminalSessionEngine( var failed = false try { service = NativeSshService(hostKeyPolicy = ::verifyHostKey) - herdrStreamService = service service.connect( host = params.host, port = params.port, @@ -1255,6 +1267,7 @@ class TerminalSessionEngine( } catch (error: CancellationException) { throw error } catch (error: Exception) { + currentCoroutineContext().ensureActive() failed = true if (isHerdrStreamCurrent(params)) { _herdrState.value = @@ -1263,7 +1276,6 @@ class TerminalSessionEngine( ) } } finally { - if (herdrStreamService === service) herdrStreamService = null service?.let { runCatching { it.close() } } } if (failed && isHerdrStreamCurrent(params)) { @@ -1277,8 +1289,6 @@ class TerminalSessionEngine( private fun stopHerdrStream() { herdrStreamJob?.cancel() herdrStreamJob = null - herdrStreamService?.let { service -> runCatching { service.close() } } - herdrStreamService = null herdrIdleStretch = 0 _herdrState.value = HerdrControlState.Inactive } diff --git a/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt index 5019fc4d..d5df736c 100644 --- a/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt @@ -308,225 +308,238 @@ fun TerminalCanvas( var lastPointerPos = down.position var autoScrollDir = 0 - while (true) { - val timeoutMs = (longPressDeadline - lastEventUptime).coerceAtLeast(1L) - val inAutoScrollZone = dragMode == DragMode.ClientSelectionDrag && autoScrollDir != 0 - val event = when { - dragMode == DragMode.None && !didScroll && !didPinch && !didDragGesture -> - withTimeoutOrNull(timeoutMs) { awaitPointerEvent() } - inAutoScrollZone -> - withTimeoutOrNull(autoScrollIntervalMs) { awaitPointerEvent() } - else -> awaitPointerEvent() - } + try { + while (true) { + val timeoutMs = (longPressDeadline - lastEventUptime).coerceAtLeast(1L) + val inAutoScrollZone = dragMode == DragMode.ClientSelectionDrag && autoScrollDir != 0 + val event = when { + dragMode == DragMode.None && !didScroll && !didPinch && !didDragGesture -> + withTimeoutOrNull(timeoutMs) { awaitPointerEvent() } + inAutoScrollZone -> + withTimeoutOrNull(autoScrollIntervalMs) { awaitPointerEvent() } + else -> awaitPointerEvent() + } - if (event == null) { - if (dragMode == DragMode.None) { - val s = currentSnapshot.value - val downPos = toSnapshotSpace(down.position, s) - if (s.appHandlesSelectionDrag) { - currentOnAppSelectionDrag.value(TerminalMouseAction.Press, downPos.x, downPos.y) - currentHaptics.value.performHapticFeedback( - HapticFeedbackType.LongPress, - ) - didDragGesture = true - dragMode = DragMode.AppMouseDrag - } else if (startSelection( - s, - downPos, - currentCellWidth.value, - currentCellHeight.value, - currentOnSelectionChange.value, - ) - ) { - currentHaptics.value.performHapticFeedback( - HapticFeedbackType.LongPress, - ) - dragMode = DragMode.ClientSelectionDrag + if (event == null) { + if (dragMode == DragMode.None) { + val s = currentSnapshot.value + val downPos = toSnapshotSpace(down.position, s) + if (s.appHandlesSelectionDrag) { + currentOnAppSelectionDrag.value(TerminalMouseAction.Press, downPos.x, downPos.y) + currentHaptics.value.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + didDragGesture = true + dragMode = DragMode.AppMouseDrag + } else if (startSelection( + s, + downPos, + currentCellWidth.value, + currentCellHeight.value, + currentOnSelectionChange.value, + ) + ) { + currentHaptics.value.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + dragMode = DragMode.ClientSelectionDrag + } + continue } + val pos = lastPointerPos + val depth = if (autoScrollDir > 0) { + pos.y - (canvasSize.height - currentAutoScrollEdgeZonePx.value) + } else { + currentAutoScrollEdgeZonePx.value - pos.y + }.coerceAtLeast(0f) + val rows = (depth / currentCellHeight.value).toInt().coerceIn(1, 8) + scrollDeltaChannel.trySend( + TerminalScrollDelta(autoScrollDir * rows, pos.x, pos.y), + ) continue } - val pos = lastPointerPos - val depth = if (autoScrollDir > 0) { - pos.y - (canvasSize.height - currentAutoScrollEdgeZonePx.value) - } else { - currentAutoScrollEdgeZonePx.value - pos.y - }.coerceAtLeast(0f) - val rows = (depth / currentCellHeight.value).toInt().coerceIn(1, 8) - scrollDeltaChannel.trySend( - TerminalScrollDelta(autoScrollDir * rows, pos.x, pos.y), - ) - continue - } - lastEventUptime = event.changes.maxOfOrNull { it.uptimeMillis } ?: lastEventUptime - val pressed = event.changes.filter { it.pressed } - if (pressed.isEmpty()) { - autoScrollDir = 0 - autoScrollingSelection = false - val releasedDragMode = dragMode - dragMode = DragMode.None - if (releasedDragMode == DragMode.AppMouseDrag) { - val s = currentSnapshot.value - val releasePos = toSnapshotSpace(lastPointerPos, s) - currentOnAppSelectionDrag.value(TerminalMouseAction.Release, releasePos.x, releasePos.y) - } - if (releasedDragMode != DragMode.None) { + lastEventUptime = event.changes.maxOfOrNull { it.uptimeMillis } ?: lastEventUptime + val pressed = event.changes.filter { it.pressed } + if (pressed.isEmpty()) { + autoScrollDir = 0 + autoScrollingSelection = false + val releasedDragMode = dragMode + dragMode = DragMode.None + if (releasedDragMode == DragMode.AppMouseDrag) { + val s = currentSnapshot.value + val releasePos = toSnapshotSpace(lastPointerPos, s) + currentOnAppSelectionDrag.value(TerminalMouseAction.Release, releasePos.x, releasePos.y) + } + if (releasedDragMode != DragMode.None) { + break + } + if (!didScroll && !didPinch && !didDragGesture) { + val tapTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: lastEventUptime + val s = currentSnapshot.value + val tapPos = toSnapshotSpace(down.position, s) + val timeSinceLastTap = tapTime - doubleTapState.lastTime + val distSinceLastTap = hypot( + (tapPos.x - doubleTapState.lastPos.x).toDouble(), + (tapPos.y - doubleTapState.lastPos.y).toDouble(), + ).toFloat() + doubleTapState.lastTime = tapTime + doubleTapState.lastPos = tapPos + + if (timeSinceLastTap < currentDoubleTapTimeoutMillis.value && + distSinceLastTap < currentDoubleTapSlopPx.value + ) { + val cellIdx = s.cellAt( + tapPos.x, + tapPos.y, + currentCellWidth.value, + currentCellHeight.value, + ) + if (cellIdx != null) { + val wordRange = s.wordAt(cellIdx) + if (wordRange != null) { + currentOnSelectionChange.value(TerminalSelection(wordRange.first, wordRange.last)) + currentHaptics.value.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + } + } + } else { + if (currentSelectionState.value != null) { + currentOnSelectionChange.value(null) + } else { + currentOnPrimaryClick.value(tapPos.x, tapPos.y) + currentOnTap.value() + } + } + } break } - if (!didScroll && !didPinch && !didDragGesture) { - val tapTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: lastEventUptime - val s = currentSnapshot.value - val tapPos = toSnapshotSpace(down.position, s) - val timeSinceLastTap = tapTime - doubleTapState.lastTime - val distSinceLastTap = hypot( - (tapPos.x - doubleTapState.lastPos.x).toDouble(), - (tapPos.y - doubleTapState.lastPos.y).toDouble(), + + if (pressed.size >= 2) { + didPinch = true + val first = pressed[0].position + val second = pressed[1].position + val distance = hypot( + (first.x - second.x).toDouble(), + (first.y - second.y).toDouble(), ).toFloat() - doubleTapState.lastTime = tapTime - doubleTapState.lastPos = tapPos - - if (timeSinceLastTap < currentDoubleTapTimeoutMillis.value && - distSinceLastTap < currentDoubleTapSlopPx.value - ) { - val cellIdx = s.cellAt( - tapPos.x, - tapPos.y, - currentCellWidth.value, - currentCellHeight.value, - ) - if (cellIdx != null) { - val wordRange = s.wordAt(cellIdx) - if (wordRange != null) { - currentOnSelectionChange.value(TerminalSelection(wordRange.first, wordRange.last)) - currentHaptics.value.performHapticFeedback( - HapticFeedbackType.LongPress, - ) - } - } - } else { - if (currentSelectionState.value != null) { - currentOnSelectionChange.value(null) - } else { - currentOnPrimaryClick.value(tapPos.x, tapPos.y) - currentOnTap.value() + if (startPinchDistance == null && distance > 0f) { + startPinchDistance = distance + anchorFontSp = currentFontSizeSp.value + lastSentSp = round(anchorFontSp) + } + val startDistance = startPinchDistance + if (startDistance != null && distance > 0f) { + val steppedSp = round(anchorFontSp * (distance / startDistance)) + .coerceIn(currentMinFontSizeSp.value, currentMaxFontSizeSp.value) + if (steppedSp != lastSentSp) { + currentOnFontSizeChange.value(steppedSp) + currentHaptics.value.performHapticFeedback( + HapticFeedbackType.TextHandleMove, + ) + lastSentSp = steppedSp } } + pressed.forEach { change -> + if (change.position != change.previousPosition) change.consume() + } + continue } - break - } - if (pressed.size >= 2) { - didPinch = true - val first = pressed[0].position - val second = pressed[1].position - val distance = hypot( - (first.x - second.x).toDouble(), - (first.y - second.y).toDouble(), - ).toFloat() - if (startPinchDistance == null && distance > 0f) { - startPinchDistance = distance - anchorFontSp = currentFontSizeSp.value - lastSentSp = round(anchorFontSp) + startPinchDistance = null + val change = pressed.firstOrNull { it.id == lastSinglePointerId } ?: pressed.first().also { + lastSinglePointerId = it.id } - val startDistance = startPinchDistance - if (startDistance != null && distance > 0f) { - val steppedSp = round(anchorFontSp * (distance / startDistance)) - .coerceIn(currentMinFontSizeSp.value, currentMaxFontSizeSp.value) - if (steppedSp != lastSentSp) { - currentOnFontSizeChange.value(steppedSp) - currentHaptics.value.performHapticFeedback( - HapticFeedbackType.TextHandleMove, - ) - lastSentSp = steppedSp + + // Selection drag takes priority once activated + val s = currentSnapshot.value + val changePos = toSnapshotSpace(change.position, s) + val changePrevPos = toSnapshotSpace(change.previousPosition, s) + val downPos = toSnapshotSpace(down.position, s) + val selectedCell = s.cellAt( + changePos.x, + changePos.y, + currentCellWidth.value, + currentCellHeight.value, + ) + if (dragMode == DragMode.AppMouseDrag) { + lastPointerPos = change.position + autoScrollDir = 0 + autoScrollingSelection = false + if (change.position != change.previousPosition) { + currentOnAppSelectionDrag.value(TerminalMouseAction.Motion, changePos.x, changePos.y) + change.consume() } + continue } - pressed.forEach { change -> - if (change.position != change.previousPosition) change.consume() + if (dragMode == DragMode.ClientSelectionDrag && selectedCell != null) { + lastPointerPos = change.position + updateSelectionDrag( + existing = currentSelectionState.value, + selectedCell = selectedCell, + onSelectionChange = currentOnSelectionChange.value, + ) + autoScrollDir = when { + change.position.y < currentAutoScrollEdgeZonePx.value -> -1 + change.position.y > canvasSize.height - currentAutoScrollEdgeZonePx.value -> 1 + else -> 0 + } + autoScrollingSelection = autoScrollDir != 0 + if (change.position != change.previousPosition) { + change.consume() + } + continue } - continue - } - - startPinchDistance = null - val change = pressed.firstOrNull { it.id == lastSinglePointerId } ?: pressed.first().also { - lastSinglePointerId = it.id - } - // Selection drag takes priority once activated - val s = currentSnapshot.value - val changePos = toSnapshotSpace(change.position, s) - val changePrevPos = toSnapshotSpace(change.previousPosition, s) - val downPos = toSnapshotSpace(down.position, s) - val selectedCell = s.cellAt( - changePos.x, - changePos.y, - currentCellWidth.value, - currentCellHeight.value, - ) - if (dragMode == DragMode.AppMouseDrag) { - lastPointerPos = change.position + val dragX = changePos.x - changePrevPos.x + val dragY = changePos.y - changePrevPos.y + val movedDistance = hypot( + (changePos.x - downPos.x).toDouble(), + (changePos.y - downPos.y).toDouble(), + ).toFloat() + val abortSlopPx = if (dragMode != DragMode.None) { + currentTouchSlopPx.value + } else { + currentLongPressSlopPx.value + } + if (movedDistance > abortSlopPx) { + didDragGesture = true + if (currentSelectionState.value != null && !selectionCleared) { + currentOnSelectionChange.value(null) + selectionCleared = true + } + } autoScrollDir = 0 autoScrollingSelection = false - if (change.position != change.previousPosition) { - currentOnAppSelectionDrag.value(TerminalMouseAction.Motion, changePos.x, changePos.y) - change.consume() + val verticalIntent = abs(dragY) > abs(dragX) * 1.2f + if (verticalIntent) { + dragRemainder += dragY / currentCellHeight.value } - continue - } - if (dragMode == DragMode.ClientSelectionDrag && selectedCell != null) { - lastPointerPos = change.position - updateSelectionDrag( - existing = currentSelectionState.value, - selectedCell = selectedCell, - onSelectionChange = currentOnSelectionChange.value, - ) - autoScrollDir = when { - change.position.y < currentAutoScrollEdgeZonePx.value -> -1 - change.position.y > canvasSize.height - currentAutoScrollEdgeZonePx.value -> 1 - else -> 0 + + if (didDragGesture && abs(dragRemainder) >= 1f) { + val delta = dragRemainder.toInt() + dragRemainder -= delta + if (delta != 0) { + didScroll = true + scrollDeltaChannel.trySend(TerminalScrollDelta(-delta, changePos.x, changePos.y)) + } } - autoScrollingSelection = autoScrollDir != 0 + if (change.position != change.previousPosition) { change.consume() } - continue - } - - val dragX = changePos.x - changePrevPos.x - val dragY = changePos.y - changePrevPos.y - val movedDistance = hypot( - (changePos.x - downPos.x).toDouble(), - (changePos.y - downPos.y).toDouble(), - ).toFloat() - val abortSlopPx = if (dragMode != DragMode.None) { - currentTouchSlopPx.value - } else { - currentLongPressSlopPx.value - } - if (movedDistance > abortSlopPx) { - didDragGesture = true - if (currentSelectionState.value != null && !selectionCleared) { - currentOnSelectionChange.value(null) - selectionCleared = true - } } - autoScrollDir = 0 + } finally { autoScrollingSelection = false - val verticalIntent = abs(dragY) > abs(dragX) * 1.2f - if (verticalIntent) { - dragRemainder += dragY / currentCellHeight.value - } - - if (didDragGesture && abs(dragRemainder) >= 1f) { - val delta = dragRemainder.toInt() - dragRemainder -= delta - if (delta != 0) { - didScroll = true - scrollDeltaChannel.trySend(TerminalScrollDelta(-delta, changePos.x, changePos.y)) - } - } - - if (change.position != change.previousPosition) { - change.consume() + if (dragMode == DragMode.AppMouseDrag) { + val s = currentSnapshot.value + val releasePos = toSnapshotSpace(lastPointerPos, s) + currentOnAppSelectionDrag.value( + TerminalMouseAction.Release, + releasePos.x, + releasePos.y, + ) } } } diff --git a/android/app/src/test/java/com/jossephus/chuchu/data/backup/ChuchuBackupCoreTest.kt b/android/app/src/test/java/com/jossephus/chuchu/data/backup/ChuchuBackupCoreTest.kt index ba39300b..6468a75b 100644 --- a/android/app/src/test/java/com/jossephus/chuchu/data/backup/ChuchuBackupCoreTest.kt +++ b/android/app/src/test/java/com/jossephus/chuchu/data/backup/ChuchuBackupCoreTest.kt @@ -26,6 +26,7 @@ class ChuchuBackupCoreTest { assertEquals(payload, decrypted) assertEquals("echo hello", decrypted.hosts.single().postConnectCommand) assertEquals(MultiplexerType.Tmux, decrypted.hosts.single().multiplexer) + assertFalse(decrypted.hosts.single().herdrNativeMode) assertFalse(String(encrypted, Charsets.ISO_8859_1).contains("PRIVATE KEY")) } @@ -187,7 +188,7 @@ class ChuchuBackupCoreTest { } @Test - fun payloadV2PreservesMultiplexerWithStableLowercaseId() { + fun payloadV3PreservesMultiplexerWithStableLowercaseId() { val encoded = ChuchuBackupCodec.encodePayload(samplePayload()) val encodedText = String(encoded, Charsets.ISO_8859_1) val decoded = ChuchuBackupCodec.decodePayload(encoded) @@ -195,10 +196,11 @@ class ChuchuBackupCoreTest { assertTrue(encodedText.contains("tmux")) assertFalse(encodedText.contains("Tmux")) assertEquals(MultiplexerType.Tmux, decoded.hosts.single().multiplexer) + assertFalse(decoded.hosts.single().herdrNativeMode) } @Test - fun payloadV2ReadsLegacyEnumMultiplexerName() { + fun payloadV3ReadsLegacyEnumMultiplexerName() { val encoded = ChuchuBackupCodec.encodePayload(samplePayload()) replaceLastAscii(encoded, "tmux", "Tmux") @@ -208,14 +210,29 @@ class ChuchuBackupCoreTest { } @Test - fun payloadV1DefaultsMultiplexerToNull() { - val v2 = ChuchuBackupCodec.encodePayload(samplePayload()) - writeIntAt(v2, offset = Int.SIZE_BYTES, value = 1) - val v1 = v2.copyOf(v2.size - encodedNullableStringSize(MultiplexerType.Tmux.id)) + fun payloadV2DefaultsHerdrNativeModeToTrue() { + val v3 = ChuchuBackupCodec.encodePayload(samplePayload()) + writeIntAt(v3, offset = Int.SIZE_BYTES, value = 2) + val v2 = v3.copyOf(v3.size - 1) + + val decoded = ChuchuBackupCodec.decodePayload(v2) + + assertTrue(decoded.hosts.single().herdrNativeMode) + assertEquals(MultiplexerType.Tmux, decoded.hosts.single().multiplexer) + } + + @Test + fun payloadV1DefaultsMultiplexerToNullAndHerdrNativeModeToTrue() { + val v3 = ChuchuBackupCodec.encodePayload(samplePayload()) + writeIntAt(v3, offset = Int.SIZE_BYTES, value = 1) + val v1 = v3.copyOf( + v3.size - 1 - encodedNullableStringSize(MultiplexerType.Tmux.id), + ) val decoded = ChuchuBackupCodec.decodePayload(v1) assertNull(decoded.hosts.single().multiplexer) + assertTrue(decoded.hosts.single().herdrNativeMode) assertEquals("echo hello", decoded.hosts.single().postConnectCommand) } @@ -266,6 +283,7 @@ class ChuchuBackupCoreTest { requireAuthOnConnect = true, postConnectCommand = "echo hello", multiplexer = MultiplexerType.Tmux, + herdrNativeMode = false, ) private fun encodedNullableStringSize(value: String): Int =