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/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/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/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/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/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/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/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/multiplexer/HerdrMultiplexer.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt new file mode 100644 index 00000000..28fa6712 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexer.kt @@ -0,0 +1,145 @@ +package com.jossephus.chuchu.service.multiplexer + +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 { + 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" + } + } + + 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, + ): String = "default" + + 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(session)}api snapshot 2>/dev/null; " + + "printf '\\nCHUCHU_SNAP_END\\n'; done" + + fun terminalSessionCommand( + paneId: String, + cols: Int, + rows: Int, + mode: HerdrStreamMode, + session: String? = null, + ): String { + val command = when (mode) { + HerdrStreamMode.Control, + HerdrStreamMode.ControlTakeover, + -> "control" + HerdrStreamMode.Observe -> "observe" + } + val takeover = if (mode == HerdrStreamMode.ControlTakeover) " --takeover" else "" + return pathPrelude + "${herdr(session)}terminal session $command ${shellQuote(paneId)} --cols $cols --rows $rows$takeover" + } + + fun focusTabCommand(tabId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}tab focus ${shellQuote(tabId)}" + + fun focusWorkspaceCommand(workspaceId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}workspace focus ${shellQuote(workspaceId)}" + + fun focusPaneCommand(paneId: String, session: String? = null): String = + pathPrelude + "${herdr(session)}agent focus ${shellQuote(paneId)}" + + 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)}" + + 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("'", "'\\''") + "'" + + @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/HerdrSnapshot.kt b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt new file mode 100644 index 00000000..23acfd95 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshot.kt @@ -0,0 +1,261 @@ +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(), + val agents: 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, +) + +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 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 + 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 + + 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 = "", + 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 +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, + 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/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..64e58471 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStream.kt @@ -0,0 +1,141 @@ +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 herdrScrollCommand(delta: Int): Pair = + if (delta >= 0) { + HerdrScrollDirection.Down to delta + } else { + HerdrScrollDirection.Up to -delta + } + +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/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/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/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/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/HerdrPaneHost.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt new file mode 100644 index 00000000..6ab6dfed --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/HerdrPaneHost.kt @@ -0,0 +1,424 @@ +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.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.cancelAndJoin +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 connectionProvider: suspend () -> SharedSshConnection, + private val runCommand: (HerdrStreamMode, Int, Int) -> String, + private val scope: CoroutineScope, +) { + private enum class StreamEnd { + Restart, + ControlRefused, + } + + private data class PendingCommand( + val channelId: Int, + 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 streamChannelId: Int? = 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 + val job = streamJob + streamJob = null + runBlocking { + job?.cancelAndJoin() + withContext(dispatcher) { + discardPendingCommands() + snapshotScheduled = false + if (handle != 0L) { + bridge.nativeDestroy(handle) + handle = 0L + } + _state.value = _state.value.copy(handle = 0L, status = HerdrPaneStreamStatus.Disposed) + } + } + dispatcher.close() + } + + private suspend fun restartStream() { + streamJob?.cancelAndJoin() + streamJob = null + streamChannelId = null + discardPendingCommands() + _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 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(connection, channelId, commandCols, commandRows) + when (readFrames(connection, channelId, 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 (streamChannelId == channelId) streamChannelId = null + val closingConnection = connection + val closingChannelId = channelId + if (closingConnection != null && closingChannelId != null) { + withContext(NonCancellable) { + runCatching { closingConnection.closeChannel(closingChannelId) } + } + } + } + if (failed && !disposed) { + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(60_000L) + } + } + } + + private suspend fun readFrames( + connection: SharedSshConnection, + channelId: Int, + mode: HerdrStreamMode, + ): StreamEnd { + val buffer = StringBuilder() + var lastSeq: Long? = null + while (currentCoroutineContext().isActive && !disposed) { + writePendingCommands(connection, 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)) { + 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 (connection.channelEof(channelId)) { + throw IllegalStateException("Herdr terminal stream ended") + } else { + delay(25) + } + } + throw CancellationException() + } + + 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( + channelId, + (herdrResizeJson(currentCols, currentRows) + "\n").toByteArray(Charsets.UTF_8), + ) + } + } + + private suspend fun writePendingCommands( + connection: SharedSshConnection, + channelId: Int, + ) { + while (true) { + val pending = pendingCommands.tryReceive().getOrNull() ?: return + if (pending.channelId == channelId) { + connection.writeChannel(channelId, (pending.json + "\n").toByteArray(Charsets.UTF_8)) + } + } + } + + 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 + 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 channelId = streamChannelId ?: return + pendingCommands.trySend(PendingCommand(channelId, 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/TabSpec.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TabSpec.kt index 12c68bf9..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 @@ -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() = @@ -51,6 +52,12 @@ data class TabSpec( transport != Transport.Mosh && transport != Transport.LocalShell + val usesHerdr: Boolean + get() = multiplexer == MultiplexerType.Herdr && usesRuntimeMultiplexer + + val usesHerdrNativeMode: Boolean + get() = usesHerdr && herdrNativeMode + companion object { fun fromHostProfile( host: HostProfile, @@ -75,6 +82,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 a1a9b8d5..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 @@ -9,12 +9,20 @@ 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.HerdrSplitDirection +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 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.SharedSshConnection import com.jossephus.chuchu.service.ssh.TailscaleStatusChecker import java.util.concurrent.Executors import kotlinx.coroutines.CancellationException @@ -24,15 +32,26 @@ 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.ensureActive 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 +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject enum class SessionStatus { @@ -85,11 +104,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, @@ -129,6 +152,12 @@ 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 + private val herdrPoke = Channel(Channel.CONFLATED) + private val herdrCadenceMs = MutableStateFlow(3_000L) + @Volatile private var herdrIdleStretch = 0 private val nativeVersion = @@ -141,6 +170,26 @@ 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() + + 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( val fg: IntArray?, val bg: IntArray?, @@ -167,8 +216,11 @@ class TerminalSessionEngine( multiplexer: MultiplexerType? = null, multiplexerSessionName: String? = null, multiplexerCreateIfMissing: Boolean = true, + herdrNativeMode: Boolean = false, ) { disconnectRequested = false + stopHerdrStream() + stopHerdrPaneStreams() val params = ConnectionParams( host = host, @@ -184,6 +236,7 @@ class TerminalSessionEngine( multiplexer = multiplexer, multiplexerSessionName = multiplexerSessionName, multiplexerCreateIfMissing = multiplexerCreateIfMissing, + herdrNativeMode = herdrNativeMode, ) lastConnectionParams = params scope.launch(dispatcher) { @@ -263,6 +316,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 +530,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 +613,49 @@ 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, lastConnectionParams?.multiplexerSessionName) } + + suspend fun herdrFocusWorkspace(workspaceId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.focusWorkspaceCommand(workspaceId, lastConnectionParams?.multiplexerSessionName) } + + suspend fun herdrFocusPane(paneId: String): MultiplexerCommandResult = + runHerdrControl { HerdrMultiplexer.focusPaneCommand(paneId, lastConnectionParams?.multiplexerSessionName) } + + 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) } + + 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() reconnectJob = null + stopHerdrStream() + stopHerdrPaneStreams() lastConnectionParams = null cancelHostKeyPrompt() scope.launch(dispatcher) { @@ -595,6 +688,112 @@ 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, + connectionProvider = { ensureHerdrConnection(params) }, + runCommand = { mode, commandCols, commandRows -> + HerdrMultiplexer.terminalSessionCommand(paneId, commandCols, commandRows, mode, params.multiplexerSessionName) + }, + scope = scope, + ) + _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 + } + } + + 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 + 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 + 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, + ) + 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 @@ -955,7 +1154,7 @@ class TerminalSessionEngine( multiplexerCreateIfMissing = multiplexerCreateIfMissing, ) - private fun runMultiplexerCommand( + private suspend fun runMultiplexerCommand( params: ConnectionParams, command: String, timeoutMs: Long = 20_000, @@ -982,7 +1181,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 +1194,159 @@ 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 connection = runCatching { ensureHerdrConnection(params) }.getOrNull() + val result = + try { + if (connection?.connected == true) { + connection.runCommand(command()) + } else { + runMultiplexerCommand(params, command()) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + invalidateHerdrConnection(connection) + MultiplexerCommandResult(1, "", error.message ?: "Herdr command failed") + } + 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) + 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(params.multiplexerSessionName))) { + throw IllegalStateException("Remote server did not open an exec channel") + } + retryDelayMs = 2_000L + streamHerdrSnapshots(params, service) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + currentCoroutineContext().ensureActive() + failed = true + if (isHerdrStreamCurrent(params)) { + _herdrState.value = + HerdrControlState.Error( + error.message ?: "Herdr snapshot stream failed", + ) + } + } finally { + service?.let { runCatching { it.close() } } + } + if (failed && isHerdrStreamCurrent(params)) { + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(60_000L) + } + } + } + } + + private fun stopHerdrStream() { + herdrStreamJob?.cancel() + herdrStreamJob = 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 +1370,8 @@ class TerminalSessionEngine( _state.value = _state.value.copy(status = SessionStatus.Disconnected) return } + stopHerdrStream() + stopHerdrPaneStreams() if (reconnectJob?.isActive == true) return reconnectJob = scope.launch(dispatcher) { @@ -1059,6 +1407,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/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt b/android/app/src/main/java/com/jossephus/chuchu/service/terminal/TerminalSessionRepository.kt index 5712895e..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 @@ -3,10 +3,14 @@ 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.MAX_RECENT_TABS 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 @@ -40,9 +44,16 @@ 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 val herdrRecentTabsByTab = mutableMapOf>() + private val herdrOptimisticFocusLock = Any() + @Volatile private var herdrOptimisticFocusTabId: String? = null private fun publishTerminalClipboard(tabId: String, text: String) { if (!canPublishTerminalClipboard(tabId)) return @@ -60,6 +71,17 @@ 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() + + // 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) @@ -112,6 +134,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 +149,8 @@ class TerminalSessionRepository private constructor(application: Application) { } } .collect { pairs -> + updateHerdrCadence() + updateHerdrPaneStreams() val anyAlive = pairs.any { (_, state) -> state.status == SessionStatus.Connecting || @@ -146,10 +173,14 @@ 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 { @@ -228,6 +259,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, @@ -243,24 +275,35 @@ class TerminalSessionRepository private constructor(application: Application) { multiplexer = spec.multiplexer, multiplexerSessionName = spec.multiplexerSessionName, multiplexerCreateIfMissing = spec.multiplexerCreateIfMissing, + herdrNativeMode = spec.herdrNativeMode, ) + updateHerdrCadence() + updateHerdrPaneStreams() return tab } fun selectTab(id: String) { if (_tabs.value.any { it.id == id }) { _activeTabId.value = id + updateHerdrCadence() + updateHerdrPaneStreams() } } fun closeTab(id: String) { 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 if (_activeTabId.value == id) { val nextSameHost = remaining.firstOrNull { it.spec.hostId == tab.spec.hostId } _activeTabId.value = nextSameHost?.id ?: remaining.firstOrNull()?.id } + updateHerdrCadence() + updateHerdrPaneStreams() tab.engine.dispose() } @@ -300,6 +343,7 @@ class TerminalSessionRepository private constructor(application: Application) { multiplexer = spec.multiplexer, multiplexerSessionName = spec.multiplexerSessionName, multiplexerCreateIfMissing = spec.multiplexerCreateIfMissing, + herdrNativeMode = spec.herdrNativeMode, ) } @@ -312,11 +356,182 @@ 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() + herdrRecentTabsByTab.clear() + _herdrFocusedPaneId.value = null + 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, + ) + } + updateHerdrPaneStreams() + } + } + } + + 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 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 + 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 && + (focusedTabIdOverride == null || focusedTabIdOverride == snapshot.focusedTabId) && + _herdrFocusedPaneId.value != snapshot.focusedPaneId + ) { + setHerdrNativeFocus(snapshot.focusedPaneId) + } + val existing = tab.engine.herdrPanes.value.keys + 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 = bootstrapCols, + rows = bootstrapRows, + cellWidthPx = bootstrapCellW, + cellHeightPx = bootstrapCellH, + focused = paneId == 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 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() + } + + fun resizeHerdrPane( + paneId: String, + cols: Int, + rows: Int, + 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, + 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}" @@ -341,30 +556,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) } @@ -377,15 +599,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) { @@ -436,6 +667,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 { 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/AddServer/AddServerScreen.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/AddServer/AddServerScreen.kt index dd849714..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( @@ -389,12 +391,15 @@ private enum class MultiplexerOption(val type: MultiplexerType?) { Tmux(MultiplexerType.Tmux), Zellij(MultiplexerType.Zellij), Zmx(MultiplexerType.Zmx), + Herdr(MultiplexerType.Herdr), } @Composable private fun MultiplexerSection( selected: MultiplexerType?, onSelect: (MultiplexerType?) -> Unit, + herdrNativeMode: Boolean, + onHerdrNativeModeChanged: (Boolean) -> Unit, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -408,11 +413,22 @@ 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) }, 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 { 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/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..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 @@ -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 @@ -212,7 +221,7 @@ fun CommandPalette( onTap = {}, onPrimaryClick = { _, _ -> }, onScroll = { _, _, _ -> }, - onZoom = {}, + onFontSizeChange = {}, onSelectionChanged = {}, modifier = Modifier.fillMaxSize(), ) @@ -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..fe02ab61 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrPanel.kt @@ -0,0 +1,291 @@ +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.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, + 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 = 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) + } +} + +@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/HerdrSplitLayout.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt new file mode 100644 index 00000000..3a85a7ee --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSplitLayout.kt @@ -0,0 +1,192 @@ +package com.jossephus.chuchu.ui.screens.Terminal + +import androidx.compose.foundation.background +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.data.repository.SettingsRepository +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, + onFontSizeChange: (Float) -> 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 + } + + 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() + } 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 = left, y = top) + .width((right - left).coerceAtLeast(0.dp)) + .height((bottom - top).coerceAtLeast(0.dp)) + .padding(if (multiPane) 0.5.dp else 0.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, + onFontSizeChange = onFontSizeChange, + 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, + onFontSizeChange: (Float) -> Unit, + onTakeover: () -> Unit, + requestInputFocus: () -> Unit, + modifier: Modifier, +) { + val typography = ChuTypography.current + Box(modifier = modifier) { + 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 = false, + 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), + 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 = { + if (focused) { + requestInputFocus() + } else { + onPaneTap(paneId) + } + }, + onPrimaryClick = { _, _ -> }, + onAppSelectionDrag = { _, _, _ -> }, + onScroll = { delta, _, _ -> onPaneScroll(paneId, delta) }, + onFontSizeChange = onFontSizeChange, + onSelectionChanged = if (focused) onSelectionChanged else { _: TerminalSelectionState? -> }, + modifier = Modifier.fillMaxSize(), + ) + } + if (!focused) { + Box( + modifier = Modifier + .fillMaxSize() + .background(colors.background.copy(alpha = 0.4f)) + .clickable { onPaneTap(paneId) }, + ) + } + 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 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..e5748977 --- /dev/null +++ b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/HerdrSwitcherHome.kt @@ -0,0 +1,469 @@ +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.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +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.HerdrTab +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 +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, + connections: List = emptyList(), + activeConnectionId: String? = null, + onSelectConnection: (String) -> Unit = {}, + 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( + 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, + ) + } + + 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, + activeConnectionId = activeConnectionId, + onSelectConnection = onSelectConnection, + 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( + "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 -> + 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 = expanded, + onToggleExpand = { expandOverrides[workspace.workspaceId] = !expanded }, + onEnterWorkspace = onEnterWorkspace, + onEnterTab = onEnterTab, + onEnterAgent = onEnterAgent, + onCloseWorkspace = onCloseWorkspace, + ) + } + } + } + } +} + +@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?.let { cleanAgentTitle(it, "") }?.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, + 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), + ) + } + 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() + .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( + 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, + ) + } + } + } +} + +@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, +) { + 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 = 12.dp, top = 8.dp, bottom = 3.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 = 6.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + 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( + "${pluralCount(workspace.tabCount, "tab")} · ${pluralCount(workspace.paneCount, "pane")}", + 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) { + 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 + 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, + 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() && !it.equals(workspaceLabel, ignoreCase = true) } + val title = agent.terminalTitleStripped?.let { cleanAgentTitle(it, workspaceLabel) }?.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) + } + title?.let { + ChuText( + it, + 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) + } +} + +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 } +} 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..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 @@ -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 @@ -326,7 +328,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 @@ -344,6 +350,9 @@ 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 pendingHerdrClose by remember { mutableStateOf(null) } var pendingTabSpec by remember { mutableStateOf(null) } var passphraseFromPicker by remember { mutableStateOf(false) } var showTabSheet by remember { mutableStateOf(false) } @@ -407,6 +416,25 @@ 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) } + 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) @@ -472,6 +500,11 @@ fun TerminalScreen( passphraseFromPicker = fromPicker pendingTabSpec = preparedSpec showPassphrasePrompt = true + } 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) } else { @@ -567,12 +600,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", @@ -613,6 +654,52 @@ 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(), + ) + } + } + + 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 @@ -677,6 +764,17 @@ fun TerminalScreen( onTabSelected = { id -> vm.selectTab(id) }, onAddTab = openAnotherSessionForCurrentHost, onOpenManager = { showGlobalTabManager = true }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrHome = vm::onShowHerdrHome, + herdrNativeMode = herdrNativeState.enabled, + herdrFocusedTabId = herdrNativeState.focusedTabId, + hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), + onHerdrSplitPane = onHerdrSplitFocusedPane, + onHerdrRequestClosePane = onHerdrRequestClosePane, + onHerdrRequestCloseTab = onHerdrRequestCloseTab, ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -761,6 +859,17 @@ fun TerminalScreen( onTabSelected = { id -> vm.selectTab(id) }, onAddTab = openAnotherSessionForCurrentHost, onOpenManager = { showGlobalTabManager = true }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrHome = vm::onShowHerdrHome, + herdrNativeMode = herdrNativeState.enabled, + herdrFocusedTabId = herdrNativeState.focusedTabId, + hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), + onHerdrSplitPane = onHerdrSplitFocusedPane, + onHerdrRequestClosePane = onHerdrRequestClosePane, + onHerdrRequestCloseTab = onHerdrRequestCloseTab, ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -789,7 +898,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) } @@ -1080,6 +1189,17 @@ fun TerminalScreen( onOpenManager = { showGlobalTabManager = true }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrHome = vm::onShowHerdrHome, + herdrNativeMode = herdrNativeState.enabled, + herdrFocusedTabId = herdrNativeState.focusedTabId, + hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), + onHerdrSplitPane = onHerdrSplitFocusedPane, + onHerdrRequestClosePane = onHerdrRequestClosePane, + onHerdrRequestCloseTab = onHerdrRequestCloseTab, ) } @@ -1245,37 +1365,92 @@ 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, + val herdrSnapshot = herdrNativeState.snapshot + when { + herdrNativeState.enabled && + herdrNativeState.homeVisible && + herdrSnapshot != null -> { + HerdrSwitcherHome( + snapshot = herdrSnapshot, + onEnterWorkspace = vm::onEnterHerdrWorkspace, + onEnterAgent = vm::onEnterHerdrAgent, + onCreateWorkspace = { showCreateWorkspacePrompt = true }, + onCloseWorkspace = { workspaceId, label -> + pendingHerdrClose = HerdrCloseTarget( + HerdrCloseKind.Workspace, workspaceId, label, ) - }, - onSelectionChanged = { state -> selectionState = state }, - ) + }, + onEnterTab = vm::onEnterHerdrTab, + colors = colors, + sessionHint = activeTab?.spec?.tabLabel, + connections = tabs, + activeConnectionId = activeTabId, + onSelectConnection = { id -> vm.selectTab(id) }, + onOpenServerList = onBack, + modifier = Modifier.fillMaxSize(), + ) + } + + 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, + onFontSizeChange = { sizeSp -> terminalFontSizeSp = sizeSp }, + 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, + 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), + 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, + onFontSizeChange = { sizeSp -> terminalFontSizeSp = sizeSp }, + onSelectionChanged = { state -> selectionState = state }, + ) + } + } + } Row( modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), @@ -1751,6 +1926,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, ) } @@ -1763,6 +1946,17 @@ fun TerminalScreen( onTabSelected = { id -> vm.selectTab(id) }, onAddTab = openAnotherSessionForCurrentHost, onOpenManager = { showGlobalTabManager = true }, + herdrEnabled = herdrUiState.enabled, + herdrState = herdrUiState.control, + onHerdrFocusTab = vm::onHerdrFocusTab, + onHerdrCreateTab = vm::onHerdrCreateTab, + onHerdrHome = vm::onShowHerdrHome, + herdrNativeMode = herdrNativeState.enabled, + herdrFocusedTabId = herdrNativeState.focusedTabId, + hostChipLabel = activeTab?.let(::terminalTabDisplayLabel), + onHerdrSplitPane = onHerdrSplitFocusedPane, + onHerdrRequestClosePane = onHerdrRequestClosePane, + onHerdrRequestCloseTab = onHerdrRequestCloseTab, ) Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -1829,6 +2023,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, ) } } @@ -1960,3 +2162,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/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/TerminalTabStrip.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/screens/Terminal/TerminalTabStrip.kt index 2124d353..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 @@ -1,24 +1,32 @@ 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 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 import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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 @@ -28,8 +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 @@ -57,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, @@ -65,6 +79,17 @@ fun TerminalTabStrip( onAddTab: () -> Unit, onOpenManager: () -> Unit, modifier: Modifier = Modifier, + herdrEnabled: Boolean = false, + herdrState: HerdrControlState = HerdrControlState.Inactive, + onHerdrFocusTab: (String) -> Unit = {}, + onHerdrCreateTab: (String) -> Unit = {}, + onHerdrHome: () -> Unit = {}, + herdrNativeMode: Boolean = false, + herdrFocusedTabId: String? = null, + hostChipLabel: String? = null, + onHerdrSplitPane: (HerdrSplitDirection) -> Unit = {}, + onHerdrRequestClosePane: () -> Unit = {}, + onHerdrRequestCloseTab: (tabId: String, label: String) -> Unit = { _, _ -> }, ) { val colors = ChuColors.current val typography = ChuTypography.current @@ -72,9 +97,23 @@ 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 focusedWorkspace = + herdrSnapshot?.workspaces?.firstOrNull { it.workspaceId == focusedWorkspaceId } + val herdrTabs = + herdrSnapshot + ?.tabs + ?.filter { it.workspaceId == focusedWorkspaceId } + ?.sortedBy { it.number } + .orEmpty() + val showHerdrTabs = herdrEnabled && herdrSnapshot != null && herdrTabs.isNotEmpty() + val focusedHerdrTabId = + herdrFocusedTabId ?: 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 +123,167 @@ 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 = + 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" 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 { if (herdrNativeMode) onHerdrHome() else 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 = herdrFocusedTabId?.let { tab.tabId == it } ?: 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 + ) + .combinedClickable( + onClick = { onHerdrFocusTab(tab.tabId) }, + onLongClick = if (herdrNativeMode) { + { onHerdrRequestCloseTab(tab.tabId, label) } + } else { + null + }, + ) + .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), + ) + } } } } @@ -162,15 +310,90 @@ fun TerminalTabStrip( } } - ChuButton( - onClick = onAddTab, - modifier = Modifier.defaultMinSize(minHeight = 32.dp, minWidth = 32.dp), - variant = ChuButtonVariant.Ghost, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), - contentDescription = "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), + ) +} 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..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 @@ -10,16 +10,24 @@ 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.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 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 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,12 +50,18 @@ 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.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) { @@ -68,6 +82,10 @@ 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 _herdrHomeVisible = MutableStateFlow(true) + private val _herdrOptimisticTabId = MutableStateFlow(null) + val herdrHomeVisible: StateFlow = _herdrHomeVisible.asStateFlow() private val _tailscaleActive = MutableStateFlow(tailscaleStatusChecker.isActive()) val tailscaleActive: StateFlow = _tailscaleActive.asStateFlow() @@ -77,6 +95,46 @@ 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.usesHerdrNativeMode) { + combine(tab.engine.herdrState, _herdrActionError) { control, actionError -> + HerdrUiState(control = control, enabled = true, actionError = actionError) + } + } else { + flowOf(HerdrUiState()) + } + } + .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, + herdrHomeVisible, + _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 == effectiveTabId }, + panes = panes, + focusedPaneId = focusedPaneId, + focusedTabId = effectiveTabId, + homeVisible = homeVisible, + ) + } + } else { + flowOf(HerdrNativeUiState()) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, HerdrNativeUiState()) val hosts: StateFlow> = hostRepository.observeAll().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) val hostsLoaded: StateFlow = @@ -84,6 +142,19 @@ 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 + } + } + viewModelScope.launch { + activeTabId.drop(1).collect { + val optimisticTabId = _herdrOptimisticTabId.value ?: return@collect + _herdrOptimisticTabId.value = null + sessionRepository.clearHerdrOptimisticFocusTab(optimisticTabId) + } + } } private val _connectionTabByTab = MutableStateFlow>(emptyMap()) @@ -412,6 +483,156 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application sessionRepository.selectTab(id) } + fun onHerdrFocusTab(tabId: String) { + showHerdrSplitsIfNative() + 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) { + showHerdrSplitsIfNative() + 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) } + } + + fun onHerdrCreateWorkspace(label: String?) { + runHerdrCommand { it.herdrCreateWorkspace(label) } + } + + fun onEnterHerdrWorkspace(workspaceId: String) { + _herdrHomeVisible.value = false + 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 + 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) } + } + + 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() + } + + private fun runHerdrCommand( + command: suspend (TerminalSessionEngine) -> MultiplexerCommandResult, + ) { + val engine = sessionRepository.activeTab.value?.engine ?: return + viewModelScope.launch { + val result = runCatching { command(engine) }.getOrElse { + MultiplexerCommandResult(1, "", it.message ?: "Herdr command failed") + } + updateHerdrActionError(result) + } + } + + private fun showHerdrSplitsIfNative() { + if (sessionRepository.activeTab.value?.spec?.usesHerdrNativeMode == true) { + _herdrHomeVisible.value = false + } + } + + 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 + 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 +977,19 @@ 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, +) + +data class HerdrNativeUiState( + val enabled: Boolean = false, + val snapshot: HerdrSnapshot? = null, + 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/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt b/android/app/src/main/java/com/jossephus/chuchu/ui/terminal/TerminalCanvas.kt index 0d6f1cc9..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 @@ -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,199 +304,242 @@ 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 - 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) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - didDragGesture = true - dragMode = DragMode.AppMouseDrag - } else if (startSelection( - s, - downPos, - cellWidth, - cellHeight, - currentOnSelectionChange.value, - ) - ) { - haptics.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 - autoScrollEdgeZonePx) - } else { - autoScrollEdgeZonePx - pos.y - }.coerceAtLeast(0f) - val rows = (depth / cellHeight).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 < doubleTapTimeoutMillis && distSinceLastTap < doubleTapSlopPx) { - val cellIdx = s.cellAt(tapPos.x, tapPos.y, cellWidth, cellHeight) - if (cellIdx != null) { - val wordRange = s.wordAt(cellIdx) - if (wordRange != null) { - currentOnSelectionChange.value(TerminalSelection(wordRange.first, wordRange.last)) - haptics.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 + } + + startPinchDistance = null + val change = pressed.firstOrNull { it.id == lastSinglePointerId } ?: pressed.first().also { + lastSinglePointerId = it.id } - break - } - if (pressed.size >= 2) { - didPinch = true + // Selection drag takes priority once activated val s = currentSnapshot.value - val first = toSnapshotSpace(pressed[0].position, s) - val second = toSnapshotSpace(pressed[1].position, s) - 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) + 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 } - lastPinchDistance = distance - 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 - } - lastPinchDistance = 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, cellWidth, cellHeight) - 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 < autoScrollEdgeZonePx -> -1 - change.position.y > canvasSize.height - autoScrollEdgeZonePx -> 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) touchSlopPx else longPressSlopPx - 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 / cellHeight - } - - 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 = 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..bac9e2b1 --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrMultiplexerTest.kt @@ -0,0 +1,189 @@ +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, + ) + } + + @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, + ), + ) + } + + @Test + fun focusWorkspaceCommandQuotesWorkspaceId() { + assertEquals( + "PATH=\"\$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:\$PATH\"; herdr workspace focus 'workspace'\\''; no'", + 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"), + ) + } +} 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..9c144e41 --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrPaneStreamPolicyTest.kt @@ -0,0 +1,226 @@ +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) + } + + @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( + 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, + ) +} 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..3118502b --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrSnapshotParserTest.kt @@ -0,0 +1,130 @@ +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) + 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) + 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 }, + ) + 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 + 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 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"}]}}}""" + } +} 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; '"), + ) + } +} 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..3f6d61d4 --- /dev/null +++ b/android/app/src/test/java/com/jossephus/chuchu/service/multiplexer/HerdrTerminalStreamTest.kt @@ -0,0 +1,112 @@ +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)), + ) + } + + @Test + fun mapsScrollDeltaToDirection() { + assertEquals(HerdrScrollDirection.Down to 3, herdrScrollCommand(3)) + assertEquals(HerdrScrollDirection.Up to 2, herdrScrollCommand(-2)) + } +} 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) + } +} 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, + ), + ) + } +} 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);