diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 384f722d8..d9debef6a 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -48,6 +48,7 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
@@ -96,6 +97,7 @@ class MeshDataHandlerImpl(
private val storeForwardHandler: StoreForwardPacketHandler,
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
+ private val remoteShellHandler: RemoteShellHandler,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
@@ -181,6 +183,12 @@ class MeshDataHandlerImpl(
adminPacketHandler.handleAdminMessage(packet, myNodeNum)
}
+ PortNum.REMOTE_SHELL_APP -> {
+ remoteShellHandler.handleRemoteShell(packet)
+ // Do not broadcast — RemoteShell frames are point-to-point PTY I/O
+ shouldBroadcast = false
+ }
+
PortNum.NEIGHBORINFO_APP -> {
neighborInfoHandler.handleNeighborInfo(packet)
shouldBroadcast = true
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RemoteShellPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RemoteShellPacketHandlerImpl.kt
new file mode 100644
index 000000000..db5c6c3da
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RemoteShellPacketHandlerImpl.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.manager
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import org.koin.core.annotation.Single
+import org.meshtastic.core.model.util.decodeOrNull
+import org.meshtastic.core.repository.ReceivedShellFrame
+import org.meshtastic.core.repository.RemoteShellHandler
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.RemoteShell
+
+/**
+ * Handles incoming [RemoteShell] packets (REMOTE_SHELL_APP portnum = 13).
+ *
+ * This is a scaffold implementation. The RemoteShell firmware feature is currently unreleased (gated to
+ * [org.meshtastic.core.model.Capabilities.supportsRemoteShell]). When the firmware ships, this handler should be
+ * expanded to manage PTY session state and relay I/O to the UI.
+ */
+@Single
+class RemoteShellPacketHandlerImpl : RemoteShellHandler {
+
+ /**
+ * Emits every received [ReceivedShellFrame] (decoded frame + sender node number).
+ *
+ * Uses [MutableSharedFlow] with a buffer so that rapid or structurally-identical frames are never silently dropped
+ * (unlike `StateFlow` which conflates by equality).
+ */
+ private val _lastFrame = MutableSharedFlow(extraBufferCapacity = 16)
+ override val lastFrame: SharedFlow = _lastFrame.asSharedFlow()
+
+ override fun handleRemoteShell(packet: MeshPacket) {
+ val payload = packet.decoded?.payload ?: return
+ val frame = RemoteShell.ADAPTER.decodeOrNull(payload, Logger) ?: return
+ Logger.d { "RemoteShell frame from ${packet.from}: op=${frame.op} sessionId=${frame.session_id}" }
+ _lastFrame.tryEmit(ReceivedShellFrame(from = packet.from, frame = frame))
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
index 022608be1..f27a0fc1b 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
@@ -45,6 +45,7 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
@@ -84,6 +85,7 @@ class MeshDataHandlerTest {
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
+ private val remoteShellHandler: RemoteShellHandler = mock(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@@ -108,6 +110,7 @@ class MeshDataHandlerTest {
storeForwardHandler = storeForwardHandler,
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
+ remoteShellHandler = remoteShellHandler,
scope = testScope,
)
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
index 4e02ae2a7..e5424c659 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -64,6 +64,12 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
val supportsEsp32Ota = atLeast(V2_7_18)
+ /**
+ * Support for the RemoteShell module (PTY-over-mesh, REMOTE_SHELL_APP portnum). Defined in protobufs HEAD
+ * (post-v2.7.21); gated to [UNRELEASED] until a firmware release ships it.
+ */
+ val supportsRemoteShell = atLeast(UNRELEASED)
+
companion object {
private val V2_6_8 = DeviceVersion("2.6.8")
private val V2_6_9 = DeviceVersion("2.6.9")
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
index 365a47c61..835376ac5 100644
--- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
@@ -85,6 +85,12 @@ class CapabilitiesTest {
assertTrue(caps("2.7.19").supportsTakConfig)
}
+ @Test
+ fun supportsRemoteShell_is_currently_unreleased() {
+ assertFalse(caps("2.7.22").supportsRemoteShell)
+ assertFalse(caps("3.0.0").supportsRemoteShell)
+ }
+
@Test
fun supportsEsp32Ota_requires_V2_7_18() {
assertFalse(caps("2.7.17").supportsEsp32Ota)
@@ -104,6 +110,7 @@ class CapabilitiesTest {
assertFalse(c.supportsStatusMessage)
assertFalse(c.supportsTrafficManagementConfig)
assertFalse(c.supportsTakConfig)
+ assertFalse(c.supportsRemoteShell)
assertFalse(c.supportsEsp32Ota)
}
@@ -115,5 +122,6 @@ class CapabilitiesTest {
assertTrue(c.supportsStatusMessage)
assertTrue(c.supportsTrafficManagementConfig)
assertTrue(c.supportsTakConfig)
+ assertTrue(c.supportsRemoteShell)
}
}
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index 7f43bf549..09fb82799 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -92,6 +92,8 @@ sealed interface NodeDetailRoute : Route {
@Serializable data class PaxMetrics(val destNum: Int) : NodeDetailRoute
@Serializable data class NeighborInfoLog(val destNum: Int) : NodeDetailRoute
+
+ @Serializable data class RemoteShell(val destNum: Int) : NodeDetailRoute
}
@Serializable
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RemoteShellHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RemoteShellHandler.kt
new file mode 100644
index 000000000..f79630db0
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RemoteShellHandler.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.repository
+
+import kotlinx.coroutines.flow.SharedFlow
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.RemoteShell
+
+/**
+ * A decoded [RemoteShell] frame together with the node number that sent it.
+ *
+ * Propagating [from] allows downstream consumers (e.g. the ViewModel) to verify that a frame actually originated from
+ * the expected peer rather than relying solely on [RemoteShell.session_id].
+ */
+data class ReceivedShellFrame(val from: Int, val frame: RemoteShell)
+
+/**
+ * Interface for handling RemoteShell packets (REMOTE_SHELL_APP portnum = 13).
+ *
+ * RemoteShell is a PTY-over-mesh feature that relays a shell session across the mesh network. The firmware-side
+ * implementation is currently unreleased (gated to [Capabilities.supportsRemoteShell]).
+ */
+interface RemoteShellHandler {
+ /**
+ * The most recently received [ReceivedShellFrame], emitted to collectors.
+ *
+ * Uses [SharedFlow] (not `StateFlow`) so that rapid or identical frames are never silently dropped.
+ */
+ val lastFrame: SharedFlow
+
+ /**
+ * Processes an incoming RemoteShell packet.
+ *
+ * @param packet The received mesh packet carrying a [RemoteShell] payload.
+ */
+ fun handleRemoteShell(packet: MeshPacket)
+}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 505d80821..64a73cf83 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -862,6 +862,8 @@
Modules unlocked
Modules already unlocked
Remote
+ Remote Shell
+ Phosphor colour
(%1$d online / %2$d shown / %3$d total)
React
Disconnect
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt
new file mode 100644
index 000000000..d8c220191
--- /dev/null
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import android.graphics.RenderEffect
+import android.graphics.RuntimeShader
+import android.os.Build
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asComposeRenderEffect
+import androidx.compose.ui.graphics.graphicsLayer
+
+/**
+ * AGSL barrel-distortion shader that simulates the curved screen of a CRT monitor.
+ *
+ * The UV coordinates are shifted from [0,1] into [-0.5, 0.5], squared, scaled by [strength], then used to warp the
+ * sample position — producing the classic pincushion/barrel look.
+ *
+ * Only applied on Android 12+ (API 31+); older devices get the no-op pass-through.
+ */
+@Suppress("MagicNumber")
+private val CRT_AGSL =
+ """
+ uniform shader image;
+ uniform float2 resolution;
+ uniform float strength;
+
+ half4 main(float2 fragCoord) {
+ float2 uv = fragCoord / resolution;
+ // Centre UV on (0,0)
+ float2 c = uv - 0.5;
+ // Barrel distortion — shift by c^2 * strength
+ float2 distorted = uv + c * dot(c, c) * strength;
+ // Clamp to avoid edge bleeding
+ distorted = clamp(distorted, float2(0.0, 0.0), float2(1.0, 1.0));
+ return image.eval(distorted * resolution);
+ }
+ """
+ .trimIndent()
+
+/** Multiplier applied to strength for a visible CRT curvature effect with the barrel-distortion shader. */
+private const val CRT_STRENGTH_SCALE = 4f
+
+@Composable
+actual fun Modifier.crtCurvature(strength: Float): Modifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val shader = remember { RuntimeShader(CRT_AGSL) }
+ graphicsLayer {
+ val w = size.width
+ val h = size.height
+ if (w > 0f && h > 0f) {
+ shader.setFloatUniform("resolution", w, h)
+ shader.setFloatUniform("strength", strength * CRT_STRENGTH_SCALE)
+ renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "image").asComposeRenderEffect()
+ }
+ }
+} else {
+ this
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt
index 23ef010e8..19bb4e71d 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt
@@ -22,20 +22,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
+import org.meshtastic.core.navigation.NodeDetailRoute
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.firmware
import org.meshtastic.core.resources.firmware_edition
+import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.installed_firmware_version
import org.meshtastic.core.resources.latest_alpha_firmware
import org.meshtastic.core.resources.latest_stable_firmware
import org.meshtastic.core.resources.remote_admin
+import org.meshtastic.core.resources.remote_shell
import org.meshtastic.core.resources.request_metadata
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.ForkLeft
@@ -79,6 +83,17 @@ fun AdministrationSection(
) {
onAction(NodeDetailAction.Navigate(SettingsRoute.Settings(node.num)))
}
+
+ if (node.capabilities.supportsRemoteShell) {
+ SectionDivider()
+
+ ListItem(
+ text = stringResource(Res.string.remote_shell),
+ leadingIcon = vectorResource(Res.drawable.ic_terminal),
+ ) {
+ onAction(NodeDetailAction.Navigate(NodeDetailRoute.RemoteShell(node.num)))
+ }
+ }
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt
new file mode 100644
index 000000000..b6b5078da
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/**
+ * Applies a barrel-distortion CRT curvature effect to the composable.
+ *
+ * On Android 12+ (API 31+) this uses [android.graphics.RenderEffect] with a [android.graphics.RuntimeShader] (AGSL) to
+ * simulate the curved screen of a classic cathode-ray tube.
+ *
+ * On all other platforms (desktop, iOS, older Android) this is a no-op.
+ *
+ * @param strength Barrel distortion factor in the range [0, 1]. 0 = flat, 1 = heavy curve.
+ */
+@Composable expect fun Modifier.crtCurvature(strength: Float = 0.08f): Modifier
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/FlickerEffect.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/FlickerEffect.kt
new file mode 100644
index 000000000..9452f6fdf
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/FlickerEffect.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import kotlinx.coroutines.delay
+import kotlin.random.Random
+
+/** Minimum brightness during a flicker dip (fraction of full alpha). */
+@Suppress("MagicNumber")
+private const val FLICKER_MIN = 0.88f
+
+/** Maximum brightness during a flicker peak. */
+private const val FLICKER_MAX = 1.00f
+
+/** Duration of a single flicker transition in milliseconds. */
+@Suppress("MagicNumber")
+private const val FLICKER_TRANSITION_MS = 80
+
+/** Delay between random flicker events in milliseconds. Keeps the effect subtle. */
+@Suppress("MagicNumber")
+private const val FLICKER_INTERVAL_MS = 1_500L
+
+/**
+ * Produces a continuously animated phosphor brightness value suitable for driving CRT flicker.
+ *
+ * The returned [State] holds a float in [[FLICKER_MIN]..[FLICKER_MAX]] that dips and recovers at random intervals,
+ * simulating the subtle intensity variation of an analogue CRT.
+ *
+ * Usage:
+ * ```
+ * val flickerAlpha by rememberFlickerAlpha()
+ * TerminalCanvas(lines, preset, flickerAlpha, showCursor)
+ * ScanlinesOverlay(preset, flickerAlpha)
+ * ```
+ *
+ * @return A [State]<[Float]> updated by the running animation.
+ */
+@Composable
+fun rememberFlickerAlpha(): State {
+ val animatable = remember { Animatable(FLICKER_MAX) }
+
+ LaunchedEffect(Unit) {
+ while (true) {
+ // Wait a randomised interval before the next dip
+ delay(FLICKER_INTERVAL_MS + Random.nextLong(0L, FLICKER_INTERVAL_MS))
+ val target = FLICKER_MIN + Random.nextFloat() * (FLICKER_MAX - FLICKER_MIN)
+ animatable.animateTo(target, animationSpec = tween(durationMillis = FLICKER_TRANSITION_MS))
+ animatable.animateTo(FLICKER_MAX, animationSpec = tween(durationMillis = FLICKER_TRANSITION_MS * 2))
+ }
+ }
+
+ return animatable.asState()
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/PhosphorPreset.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/PhosphorPreset.kt
new file mode 100644
index 000000000..6be8af332
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/PhosphorPreset.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Phosphor colour presets for the RetroShell terminal.
+ * - [GREEN] — P1 phosphor, VT100 / IBM 3101 style (#33FF33 on near-black)
+ * - [AMBER] — P3 phosphor, IBM 3278 / Televideo 925 style (#FFB000 on near-black)
+ * - [WHITE] — P4 phosphor, DEC VT220 / paper-white style (#E0E0E0 on near-black)
+ */
+@Suppress("MagicNumber")
+enum class PhosphorPreset(
+ /** The main text / glyph colour. */
+ val fg: Color,
+ /** The terminal background — deliberately slightly off-black to avoid pure-black crush. */
+ val bg: Color,
+ /** A dimmer variant used for scanline fill and unfocused UI elements. */
+ val dim: Color,
+ /** Glow halo colour — same hue as [fg] but very transparent. */
+ val glow: Color,
+) {
+ GREEN(fg = Color(0xFF33FF33), bg = Color(0xFF0A0F0A), dim = Color(0xFF0D2B0D), glow = Color(0x4033FF33)),
+ AMBER(fg = Color(0xFFFFB000), bg = Color(0xFF100C00), dim = Color(0xFF2E1E00), glow = Color(0x40FFB000)),
+ WHITE(fg = Color(0xFFE0E0E0), bg = Color(0xFF0C0C0C), dim = Color(0xFF222222), glow = Color(0x40E0E0E0)),
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellScreen.kt
new file mode 100644
index 000000000..17ec8d969
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellScreen.kt
@@ -0,0 +1,235 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.delay
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.ic_terminal
+import org.meshtastic.core.resources.phosphor_colour
+import org.meshtastic.core.resources.remote_shell
+import org.meshtastic.core.ui.component.MainAppBar
+
+/** Cursor blink period in milliseconds. */
+@Suppress("MagicNumber")
+private const val CURSOR_BLINK_MS = 530L
+
+/**
+ * Full retro-CRT terminal screen for the RemoteShell feature (portnum = 13).
+ *
+ * ### Input model
+ * Input is **raw / streaming** — there is no visible text field. A zero-size [BasicTextField] holds keyboard focus and
+ * is the sole entry point for both hardware key events and the Android soft keyboard.
+ * - Each printable character is routed to [RemoteShellViewModel.typeKey].
+ * - Enter / newline is routed to [RemoteShellViewModel.typeEnter] (immediate flush).
+ * - Backspace is routed to [RemoteShellViewModel.typeBackspace].
+ * - The ViewModel batches keystrokes and flushes over the mesh after a 50 ms debounce or when the 64-byte buffer fills.
+ *
+ * A small "tap to type" button at the bottom re-acquires focus when the soft keyboard is dismissed.
+ *
+ * ### Pending input rendering
+ * Unflushed characters from [RemoteShellViewModel.pendingInput] are passed to [TerminalCanvas] and drawn inline after
+ * the last confirmed output line using [PhosphorPreset.dim], giving immediate visual feedback without implying the
+ * bytes have been transmitted.
+ *
+ * @param viewModel [RemoteShellViewModel] for this destination node.
+ * @param onNavigateUp Callback invoked when the user presses the navigation-up button.
+ */
+@Suppress("LongMethod", "CyclomaticComplexMethod")
+@Composable
+fun RemoteShellScreen(viewModel: RemoteShellViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) {
+ val outputLines by viewModel.outputLines.collectAsStateWithLifecycle()
+ val pendingInput by viewModel.pendingInput.collectAsStateWithLifecycle()
+ val phosphor by viewModel.phosphor.collectAsStateWithLifecycle()
+
+ // Cursor blink
+ var showCursor by remember { mutableStateOf(true) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(CURSOR_BLINK_MS)
+ showCursor = !showCursor
+ }
+ }
+
+ // Open session on composition entry
+ LaunchedEffect(Unit) { viewModel.openSession() }
+
+ // Flicker animation
+ val flickerAlpha by rememberFlickerAlpha()
+
+ // Phosphor picker dropdown state
+ var dropdownExpanded by remember { mutableStateOf(false) }
+
+ // Focus management for the hidden input sink
+ val focusRequester = remember { FocusRequester() }
+
+ // Request focus when the screen first appears
+ LaunchedEffect(Unit) {
+ // Small delay to let the composition settle before requesting focus
+ delay(FOCUS_REQUEST_DELAY_MS)
+ focusRequester.requestFocus()
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ MainAppBar(
+ title = viewModel.nodeLongName,
+ subtitle = stringResource(Res.string.remote_shell),
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onNavigateUp,
+ actions = {
+ Box {
+ IconButton(onClick = { dropdownExpanded = true }) {
+ Icon(
+ painter = painterResource(Res.drawable.ic_terminal),
+ contentDescription = stringResource(Res.string.phosphor_colour),
+ tint = phosphor.fg,
+ )
+ }
+ DropdownMenu(expanded = dropdownExpanded, onDismissRequest = { dropdownExpanded = false }) {
+ PhosphorPreset.entries.forEach { preset ->
+ DropdownMenuItem(
+ text = {
+ Text(text = preset.name, color = preset.fg, fontFamily = FontFamily.Monospace)
+ },
+ onClick = {
+ viewModel.setPhosphor(preset)
+ dropdownExpanded = false
+ },
+ modifier = Modifier.background(preset.bg),
+ )
+ }
+ }
+ }
+ },
+ onClickChip = {},
+ )
+ },
+ containerColor = phosphor.bg,
+ ) { paddingValues ->
+ Box(modifier = Modifier.fillMaxSize().padding(paddingValues).imePadding()) {
+ // --- Terminal surface ---
+ Box(
+ modifier =
+ Modifier.fillMaxSize()
+ .crtCurvature()
+ // Tapping anywhere on the terminal re-acquires keyboard focus
+ .clickable { focusRequester.requestFocus() },
+ ) {
+ TerminalCanvas(
+ lines = outputLines,
+ pendingInput = pendingInput,
+ preset = phosphor,
+ flickerAlpha = flickerAlpha,
+ showCursor = showCursor,
+ modifier = Modifier.fillMaxSize(),
+ )
+ ScanlinesOverlay(preset = phosphor, flickerAlpha = flickerAlpha, modifier = Modifier.fillMaxSize())
+ }
+
+ // --- Hidden keyboard sink ---
+ // Zero-size BasicTextField that holds focus so both hardware and soft keyboard input
+ // is captured. onKeyEvent handles hardware keys (including backspace and enter);
+ // onValueChange handles soft-keyboard input where key events may not fire.
+ BasicTextField(
+ value = "",
+ onValueChange = { newText ->
+ // Soft keyboard delivers text via onValueChange. Since we always reset to ""
+ // the entire newText string is fresh input.
+ newText.forEach { char ->
+ when {
+ char == '\n' || char == '\r' -> viewModel.typeEnter()
+ char == '\b' -> viewModel.typeBackspace()
+ char == '\t' -> viewModel.typeKey('\t') // tab: immediate flush in VM
+ char.isISOControl() -> Unit // ignore other control chars
+ else -> viewModel.typeKey(char)
+ }
+ }
+ },
+ modifier =
+ Modifier.size(1.dp) // zero visible footprint
+ .align(Alignment.BottomStart)
+ .focusRequester(focusRequester)
+ .onKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
+ when (event.key) {
+ Key.Enter,
+ Key.NumPadEnter,
+ -> {
+ viewModel.typeEnter()
+ true
+ }
+ Key.Tab -> {
+ viewModel.typeKey('\t')
+ true
+ }
+ Key.Backspace -> {
+ viewModel.typeBackspace()
+ true
+ }
+ else -> false
+ }
+ },
+ textStyle = androidx.compose.ui.text.TextStyle(color = Color.Transparent, fontSize = 1.sp),
+ cursorBrush = SolidColor(Color.Transparent),
+ )
+ }
+ }
+}
+
+/** Delay before the initial focus request, in milliseconds. */
+@Suppress("MagicNumber")
+private const val FOCUS_REQUEST_DELAY_MS = 100L
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt
new file mode 100644
index 000000000..dd7ee67f0
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt
@@ -0,0 +1,712 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import okio.Buffer
+import okio.ByteString
+import okio.ByteString.Companion.toByteString
+import org.koin.core.annotation.InjectedParam
+import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RemoteShellHandler
+import org.meshtastic.core.ui.viewmodel.safeLaunch
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.RemoteShell
+import kotlin.concurrent.Volatile
+
+// ---------------------------------------------------------------------------
+// Protocol constants (matched to dmshell_client.py / DMShell.cpp)
+// ---------------------------------------------------------------------------
+
+/** Maximum number of output lines held in the ring buffer before the oldest are dropped. */
+private const val MAX_OUTPUT_LINES = 500
+
+/** Default PTY column count sent in OPEN/RESIZE frames. */
+private const val DEFAULT_COLS = 80
+
+/** Default PTY row count sent in OPEN/RESIZE frames. */
+private const val DEFAULT_ROWS = 24
+
+/**
+ * Maximum payload bytes per INPUT frame. Matches the firmware constant (meshtastic_Constants_DATA_PAYLOAD_LEN is 237
+ * but the POC client caps at 64 per batch for radio efficiency).
+ */
+private const val MAX_INPUT_CHUNK_BYTES = 64
+
+/**
+ * Default debounce window in milliseconds. Matches the Python client's INPUT_BATCH_WINDOW_SEC = 0.5 s. Configurable via
+ * [RemoteShellViewModel.setFlushWindowMs].
+ */
+private const val DEFAULT_FLUSH_WINDOW_MS = 500L
+
+/** Number of sent frames to keep in the retransmission history ring buffer. */
+private const val TX_HISTORY_MAX = 50
+
+/** Idle period (ms) after which the first heartbeat PING is sent. */
+private const val HEARTBEAT_IDLE_DELAY_MS = 5_000L
+
+/** Interval (ms) between repeated heartbeat PINGs while the session is otherwise idle. */
+private const val HEARTBEAT_REPEAT_MS = 15_000L
+
+/** Poll interval (ms) for the heartbeat coroutine. */
+private const val HEARTBEAT_POLL_MS = 250L
+
+/** Minimum ms between re-requesting the same missing sequence number. */
+private const val MISSING_SEQ_RETRY_MS = 1_000L
+
+/** Maximum configurable flush window in milliseconds. */
+private const val MAX_FLUSH_WINDOW_MS = 5_000L
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Size in bytes of an encoded uint32 (big-endian). */
+private const val UINT32_BYTES = 4
+
+private fun decodeUint32BE(bytes: ByteArray, offset: Int = 0): Int =
+ Buffer().apply { write(bytes, offset, UINT32_BYTES) }.readInt()
+
+// ---------------------------------------------------------------------------
+// Sent-frame record for retransmission history
+// ---------------------------------------------------------------------------
+
+private data class SentFrame(
+ val op: RemoteShell.OpCode,
+ val sessionId: Int,
+ val seq: Int,
+ val ackSeq: Int,
+ val payload: ByteString,
+ val cols: Int,
+ val rows: Int,
+ val flags: Int = 0,
+ val lastTxSeq: Int = 0,
+ val lastRxSeq: Int = 0,
+)
+
+// ---------------------------------------------------------------------------
+// ViewModel
+// ---------------------------------------------------------------------------
+
+/**
+ * ViewModel for the RemoteShell terminal screen.
+ *
+ * ### Protocol fidelity
+ * Implements the same reliability layer as `dmshell_client.py`:
+ * - Every non-ACK outbound frame carries an incrementing [nextTxSeq] and a piggybacked [lastRxSeq] in the `ack_seq`
+ * field.
+ * - Out-of-order inbound frames are buffered; a gap triggers an `ACK` with a 4-byte `REPLAY_REQUEST` payload asking the
+ * sender to retransmit from the first missing seq.
+ * - The last [TX_HISTORY_MAX] sent frames are kept in [txHistory] for retransmission on request.
+ * - PING/PONG heartbeats carry an 8-byte status payload `(lastTxSeq, lastRxSeq)`. On PONG, if the peer is behind our tx
+ * cursor we replay; if the peer reports frames we haven't seen we request a replay.
+ *
+ * ### PKI
+ * Outbound [DataPacket]s use [DataPacket.PKC_CHANNEL_INDEX] so that `CommandSenderImpl` applies Curve25519 encryption
+ * before handing the frame to the radio. The firmware rejects DMShell packets that are not PKI-encrypted.
+ *
+ * ### Input model
+ * Raw streaming input with a configurable debounce flush window ([DEFAULT_FLUSH_WINDOW_MS] = 500 ms, matching the
+ * Python client). Flush is also triggered immediately when the buffer reaches [MAX_INPUT_CHUNK_BYTES] or when the user
+ * types a line terminator (`\n`, `\r`) or a tab (`\t`).
+ *
+ * ### Pending-input visibility
+ * Unflushed keystrokes are exposed via [pendingInput] and rendered dim in the UI until the batch is sent
+ * (snap-to-confirmed on flush).
+ */
+@Suppress("TooManyFunctions")
+@KoinViewModel
+class RemoteShellViewModel(
+ @InjectedParam val destNum: Int,
+ private val dispatchers: CoroutineDispatchers,
+ private val nodeRepository: NodeRepository,
+ private val commandSender: CommandSender,
+ private val remoteShellHandler: RemoteShellHandler,
+) : ViewModel() {
+
+ // region --- Session state ---
+
+ enum class SessionState {
+ IDLE,
+ OPENING,
+ OPEN,
+ CLOSING,
+ CLOSED,
+ ERROR,
+ }
+
+ private val _sessionState = MutableStateFlow(SessionState.IDLE)
+ val sessionState: StateFlow = _sessionState.asStateFlow()
+
+ companion object {
+ private val OPENABLE_STATES = setOf(SessionState.IDLE, SessionState.CLOSED, SessionState.ERROR)
+ }
+
+ /** Session ID negotiated during OPEN / OPEN_OK exchange. */
+ private val sessionId = MutableStateFlow(0)
+
+ /** Remote PTY PID received in the OPEN_OK payload (0 if not provided). */
+ private val _remotePid = MutableStateFlow(0)
+ val remotePid: StateFlow = _remotePid.asStateFlow()
+
+ // endregion
+
+ // region --- Outbound sequence / retransmission ---
+
+ /** Next seq to assign to an outbound non-ACK frame. */
+ @Volatile private var nextTxSeq: Int = 1
+
+ /** Ring buffer of the last [TX_HISTORY_MAX] sent frames for replay. */
+ private val txHistory = ArrayDeque()
+ private val txMutex = Mutex()
+
+ private suspend fun allocSeq(): Int = txMutex.withLock { nextTxSeq++ }
+
+ private suspend fun rememberSent(frame: SentFrame) {
+ if (frame.seq == 0 || frame.op == RemoteShell.OpCode.ACK) return
+ txMutex.withLock {
+ txHistory.addLast(frame)
+ if (txHistory.size > TX_HISTORY_MAX) txHistory.removeFirst()
+ }
+ }
+
+ private suspend fun pruneSentFrames(ackSeq: Int) {
+ if (ackSeq <= 0) return
+ txMutex.withLock { txHistory.removeAll { it.seq <= ackSeq } }
+ }
+
+ private suspend fun replayFrom(startSeq: Int) {
+ val frame = txMutex.withLock { txHistory.firstOrNull { it.seq == startSeq } }
+ if (frame == null) {
+ Logger.w { "RemoteShell replay unavailable for seq=$startSeq" }
+ return
+ }
+ Logger.d { "RemoteShell replaying seq=${frame.seq} op=${frame.op}" }
+ sendFrame(
+ RemoteShell(
+ op = frame.op,
+ session_id = frame.sessionId,
+ seq = frame.seq,
+ ack_seq = currentAckSeq(),
+ payload = frame.payload,
+ cols = frame.cols,
+ rows = frame.rows,
+ flags = frame.flags,
+ last_tx_seq = frame.lastTxSeq,
+ last_rx_seq = frame.lastRxSeq,
+ ),
+ remember = false,
+ )
+ }
+
+ private suspend fun currentAckSeq(): Int = rxMutex.withLock { lastRxSeq }
+
+ private suspend fun highestSentSeq(): Int = txMutex.withLock { nextTxSeq - 1 }
+
+ // endregion
+
+ // region --- Inbound sequence tracking ---
+
+ private val rxMutex = Mutex()
+
+ /** Highest in-order rx seq we have delivered to the output buffer. */
+ @Volatile private var lastRxSeq: Int = 0
+
+ /** Next rx seq we expect to receive in order. */
+ @Volatile private var nextExpectedRxSeq: Int = 1
+
+ /** Highest seq we have ever seen from the peer (may be ahead of [nextExpectedRxSeq]). */
+ @Volatile private var highestSeenRxSeq: Int = 0
+
+ /** Out-of-order frames buffered while waiting for a gap to fill. */
+ private val pendingRxFrames = mutableMapOf()
+
+ /** Tracks when we last requested a specific missing seq, to rate-limit retries. */
+ @Volatile private var lastRequestedMissingSeq: Int = 0
+
+ @Volatile private var lastMissingRequestTimeMs: Long = 0L
+
+ private enum class RxAction {
+ PROCESS,
+ GAP,
+ DUPLICATE,
+ }
+
+ private suspend fun noteReceivedSeq(seq: Int): RxAction = rxMutex.withLock {
+ if (seq == 0) return@withLock RxAction.PROCESS
+ when {
+ seq < nextExpectedRxSeq -> RxAction.DUPLICATE
+ seq > nextExpectedRxSeq -> {
+ if (seq > highestSeenRxSeq) highestSeenRxSeq = seq
+ RxAction.GAP
+ }
+ else -> {
+ lastRxSeq = seq
+ nextExpectedRxSeq = seq + 1
+ if (seq > highestSeenRxSeq) highestSeenRxSeq = seq
+ if (lastRequestedMissingSeq != 0 && nextExpectedRxSeq > lastRequestedMissingSeq) {
+ lastRequestedMissingSeq = 0
+ }
+ RxAction.PROCESS
+ }
+ }
+ }
+
+ private suspend fun requestMissingSeqOnce(): Int? = rxMutex.withLock {
+ if (highestSeenRxSeq < nextExpectedRxSeq) return@withLock null
+ val now = nowMillis
+ if (
+ lastRequestedMissingSeq == nextExpectedRxSeq && (now - lastMissingRequestTimeMs) < MISSING_SEQ_RETRY_MS
+ ) {
+ return@withLock null
+ }
+ lastRequestedMissingSeq = nextExpectedRxSeq
+ lastMissingRequestTimeMs = now
+ nextExpectedRxSeq
+ }
+
+ // endregion
+
+ // region --- Output buffer ---
+
+ private val _outputLines = MutableStateFlow>(emptyList())
+ val outputLines: StateFlow> = _outputLines.asStateFlow()
+
+ // endregion
+
+ // region --- Raw input / pending buffer ---
+
+ private val inputBuffer = StringBuilder()
+
+ private val _pendingInput = MutableStateFlow("")
+ val pendingInput: StateFlow = _pendingInput.asStateFlow()
+
+ private var flushJob: Job? = null
+
+ private val _flushWindowMs = MutableStateFlow(DEFAULT_FLUSH_WINDOW_MS)
+ val flushWindowMs: StateFlow = _flushWindowMs.asStateFlow()
+
+ fun setFlushWindowMs(ms: Long) {
+ _flushWindowMs.value = ms.coerceIn(0L, MAX_FLUSH_WINDOW_MS)
+ }
+
+ // endregion
+
+ // region --- Terminal size ---
+
+ private val _cols = MutableStateFlow(DEFAULT_COLS)
+ val cols: StateFlow = _cols.asStateFlow()
+
+ private val _rows = MutableStateFlow(DEFAULT_ROWS)
+ val rows: StateFlow = _rows.asStateFlow()
+
+ // endregion
+
+ // region --- Phosphor colour pref ---
+
+ private val _phosphor = MutableStateFlow(PhosphorPreset.GREEN)
+ val phosphor: StateFlow = _phosphor.asStateFlow()
+
+ fun setPhosphor(preset: PhosphorPreset) {
+ _phosphor.value = preset
+ }
+
+ // endregion
+
+ // region --- Heartbeat timing ---
+
+ @Volatile private var lastActivityMs: Long = nowMillis
+
+ @Volatile private var lastHeartbeatSentMs: Long = 0L
+
+ private fun noteActivity(isHeartbeat: Boolean = false) {
+ val now = nowMillis
+ if (isHeartbeat) lastHeartbeatSentMs = now else lastActivityMs = now
+ }
+
+ private fun isHeartbeatDue(): Boolean {
+ val now = nowMillis
+ if ((now - lastActivityMs) < HEARTBEAT_IDLE_DELAY_MS) return false
+ return lastHeartbeatSentMs <= lastActivityMs || (now - lastHeartbeatSentMs) >= HEARTBEAT_REPEAT_MS
+ }
+
+ // endregion
+
+ // region --- Node info ---
+
+ val nodeLongName: String
+ get() = nodeRepository.nodeDBbyNum.value[destNum]?.user?.long_name ?: destNum.toString()
+
+ // endregion
+
+ // region --- Session control ---
+
+ fun openSession() {
+ if (_sessionState.value !in OPENABLE_STATES) return
+ _sessionState.update { SessionState.OPENING }
+ viewModelScope.launch {
+ val newSessionId = commandSender.generatePacketId()
+ sessionId.update { newSessionId }
+ rxMutex.withLock {
+ lastRxSeq = 0
+ nextExpectedRxSeq = 1
+ highestSeenRxSeq = 0
+ pendingRxFrames.clear()
+ }
+ txMutex.withLock { nextTxSeq = 1 }
+ sendFrame(
+ RemoteShell(
+ op = RemoteShell.OpCode.OPEN,
+ session_id = newSessionId,
+ seq = allocSeq(),
+ cols = _cols.value,
+ rows = _rows.value,
+ ),
+ )
+ Logger.d { "RemoteShell OPEN → destNum=$destNum sessionId=$newSessionId" }
+ startHeartbeatLoop()
+ }
+ }
+
+ fun closeSession() {
+ if (_sessionState.value != SessionState.OPEN) return
+ _sessionState.update { SessionState.CLOSING }
+ viewModelScope.launch {
+ sendFrame(
+ RemoteShell(
+ op = RemoteShell.OpCode.CLOSE,
+ session_id = sessionId.value,
+ seq = allocSeq(),
+ ack_seq = currentAckSeq(),
+ ),
+ )
+ }
+ }
+
+ fun resize(cols: Int, rows: Int) {
+ _cols.value = cols
+ _rows.value = rows
+ if (_sessionState.value == SessionState.OPEN) {
+ viewModelScope.launch {
+ sendFrame(
+ RemoteShell(
+ op = RemoteShell.OpCode.RESIZE,
+ session_id = sessionId.value,
+ seq = allocSeq(),
+ ack_seq = currentAckSeq(),
+ cols = cols,
+ rows = rows,
+ ),
+ )
+ }
+ }
+ }
+
+ // endregion
+
+ // region --- Raw input handling ---
+
+ /**
+ * Appends [char] to the pending buffer and schedules a debounced flush. Flushes immediately on line-terminators and
+ * tab to match the Python client's early-flush heuristic, and when the buffer reaches [MAX_INPUT_CHUNK_BYTES].
+ */
+ fun typeKey(char: Char) {
+ inputBuffer.append(char)
+ _pendingInput.value = inputBuffer.toString()
+ when {
+ char == '\n' || char == '\r' || char == '\t' -> flushBuffer()
+ inputBuffer.length >= MAX_INPUT_CHUNK_BYTES -> flushBuffer()
+ else -> scheduleFlush()
+ }
+ }
+
+ /** Appends `\r` and flushes immediately (Enter key on mobile). */
+ fun typeEnter() {
+ inputBuffer.append('\r')
+ _pendingInput.value = inputBuffer.toString()
+ flushBuffer()
+ }
+
+ /** Removes the last byte from the pending buffer; cancels or reschedules the flush job. */
+ fun typeBackspace() {
+ if (inputBuffer.isEmpty()) return
+ inputBuffer.deleteAt(inputBuffer.lastIndex)
+ _pendingInput.value = inputBuffer.toString()
+ if (inputBuffer.isEmpty()) {
+ flushJob?.cancel()
+ flushJob = null
+ } else {
+ scheduleFlush()
+ }
+ }
+
+ private fun scheduleFlush() {
+ flushJob?.cancel()
+ flushJob =
+ viewModelScope.launch {
+ delay(_flushWindowMs.value)
+ flushBuffer()
+ }
+ }
+
+ private fun flushBuffer() {
+ flushJob?.cancel()
+ flushJob = null
+ if (inputBuffer.isEmpty()) return
+
+ val text = inputBuffer.toString()
+ inputBuffer.clear()
+ _pendingInput.value = ""
+
+ if (_sessionState.value != SessionState.OPEN) return
+
+ // No local echo — unflushed keystrokes are shown via pendingInput (rendered dim) and the
+ // remote PTY will echo them back as OUTPUT frames once the INPUT is delivered.
+
+ val payload = text.encodeToByteArray().toByteString()
+ viewModelScope.launch {
+ var offset = 0
+ while (offset < payload.size) {
+ val end = (offset + MAX_INPUT_CHUNK_BYTES).coerceAtMost(payload.size)
+ sendFrame(
+ RemoteShell(
+ op = RemoteShell.OpCode.INPUT,
+ session_id = sessionId.value,
+ seq = allocSeq(),
+ ack_seq = currentAckSeq(),
+ payload = payload.substring(offset, end),
+ ),
+ )
+ offset = end
+ }
+ }
+ }
+
+ // endregion
+
+ // region --- Heartbeat loop ---
+
+ private var heartbeatJob: Job? = null
+
+ @Suppress("LoopWithTooManyJumpStatements")
+ private fun startHeartbeatLoop() {
+ heartbeatJob?.cancel()
+ heartbeatJob =
+ safeLaunch(context = dispatchers.io, tag = "remoteShellHeartbeat") {
+ val terminalStates = setOf(SessionState.CLOSED, SessionState.ERROR, SessionState.CLOSING)
+ while (_sessionState.value !in terminalStates) {
+ delay(HEARTBEAT_POLL_MS)
+ if (_sessionState.value != SessionState.OPEN) continue
+ if (!isHeartbeatDue()) continue
+ sendFrame(
+ RemoteShell(
+ op = RemoteShell.OpCode.PING,
+ session_id = sessionId.value,
+ seq = allocSeq(),
+ ack_seq = currentAckSeq(),
+ last_tx_seq = highestSentSeq(),
+ last_rx_seq = currentAckSeq(),
+ ),
+ isHeartbeat = true,
+ )
+ }
+ }
+ }
+
+ // endregion
+
+ // region --- Inbound frame processing ---
+
+ init {
+ safeLaunch(context = dispatchers.io, tag = "remoteShellFrameCollector") {
+ remoteShellHandler.lastFrame.collect { (from, frame) ->
+ if (from != destNum) return@collect
+ val ourSession = sessionId.value
+ if (ourSession != 0 && frame.session_id != ourSession) return@collect
+ noteActivity()
+ processFrame(frame)
+ }
+ }
+ }
+
+ @Suppress("CyclomaticComplexMethod")
+ private suspend fun processFrame(frame: RemoteShell) {
+ pruneSentFrames(frame.ack_seq)
+
+ if (frame.op == RemoteShell.OpCode.ACK) {
+ if (frame.last_rx_seq > 0) {
+ replayFrom(frame.last_rx_seq + 1)
+ }
+ return
+ }
+
+ when (noteReceivedSeq(frame.seq)) {
+ RxAction.DUPLICATE -> {
+ requestMissingSeqOnce()?.let { sendAck(replayFrom = it) }
+ }
+ RxAction.GAP -> {
+ rxMutex.withLock { pendingRxFrames[frame.seq] = frame }
+ requestMissingSeqOnce()?.let { sendAck(replayFrom = it) }
+ }
+ RxAction.PROCESS -> {
+ handleInOrderFrame(frame)
+ drainPendingRxFrames()
+ requestMissingSeqOnce()?.let { sendAck(replayFrom = it) }
+ }
+ }
+ }
+
+ @Suppress("LoopWithTooManyJumpStatements")
+ private suspend fun drainPendingRxFrames() {
+ while (true) {
+ val next = rxMutex.withLock { pendingRxFrames.remove(nextExpectedRxSeq) } ?: break
+ if (noteReceivedSeq(next.seq) == RxAction.PROCESS) {
+ handleInOrderFrame(next)
+ } else {
+ rxMutex.withLock { pendingRxFrames[next.seq] = next }
+ break
+ }
+ }
+ }
+
+ @Suppress("CyclomaticComplexMethod", "ReturnCount")
+ private suspend fun handleInOrderFrame(frame: RemoteShell) {
+ when (frame.op) {
+ RemoteShell.OpCode.OPEN_OK -> {
+ _sessionState.update { SessionState.OPEN }
+ val payload = frame.payload.toByteArray()
+ if (payload.size >= UINT32_BYTES) {
+ _remotePid.update { decodeUint32BE(payload) }
+ }
+ Logger.i { "RemoteShell OPEN_OK session=${frame.session_id} pid=${_remotePid.value}" }
+ }
+ RemoteShell.OpCode.OUTPUT -> {
+ val text =
+ frame.payload.utf8().ifEmpty {
+ return
+ }
+ text.lines().forEach { appendOutput(it) }
+ }
+ RemoteShell.OpCode.ERROR -> {
+ appendOutput("[error] ${frame.payload.utf8().ifEmpty { "unknown error" }}")
+ _sessionState.update { SessionState.ERROR }
+ }
+ RemoteShell.OpCode.CLOSED -> {
+ val msg = frame.payload.utf8()
+ appendOutput(if (msg.isNotEmpty()) "[session closed: $msg]" else "[session closed]")
+ _sessionState.update { SessionState.CLOSED }
+ }
+ RemoteShell.OpCode.PONG -> {
+ val peerLastTxSeq = frame.last_tx_seq
+ val peerLastRxSeq = frame.last_rx_seq
+ val ourHighestTx = highestSentSeq()
+ if (peerLastRxSeq in 1.. currentAckSeq()) {
+ rxMutex.withLock { if (peerLastTxSeq > highestSeenRxSeq) highestSeenRxSeq = peerLastTxSeq }
+ requestMissingSeqOnce()?.let { sendAck(replayFrom = it) }
+ }
+ }
+ else -> Logger.d { "RemoteShell unhandled in-order op=${frame.op}" }
+ }
+ }
+
+ // endregion
+
+ // region --- Frame dispatch ---
+
+ private suspend fun sendAck(replayFrom: Int? = null) {
+ sendFrame(
+ RemoteShell(
+ op = RemoteShell.OpCode.ACK,
+ session_id = sessionId.value,
+ seq = 0,
+ ack_seq = currentAckSeq(),
+ last_rx_seq = replayFrom?.let { it - 1 } ?: 0,
+ ),
+ remember = false,
+ )
+ }
+
+ private suspend fun sendFrame(frame: RemoteShell, remember: Boolean = true, isHeartbeat: Boolean = false) {
+ if (remember) {
+ rememberSent(
+ SentFrame(
+ op = frame.op,
+ sessionId = frame.session_id,
+ seq = frame.seq,
+ ackSeq = frame.ack_seq,
+ payload = frame.payload,
+ cols = frame.cols,
+ rows = frame.rows,
+ flags = frame.flags,
+ lastTxSeq = frame.last_tx_seq,
+ lastRxSeq = frame.last_rx_seq,
+ ),
+ )
+ }
+ noteActivity(isHeartbeat)
+ safeLaunch(context = dispatchers.io, tag = "remoteShellSend") {
+ val myNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0
+ val packet =
+ DataPacket(
+ to = DataPacket.nodeNumToDefaultId(destNum),
+ from = DataPacket.nodeNumToDefaultId(myNum),
+ bytes = RemoteShell.ADAPTER.encode(frame).toByteString(),
+ dataType = PortNum.REMOTE_SHELL_APP.value,
+ // PKC_CHANNEL_INDEX (8) triggers Curve25519 encryption in CommandSenderImpl.
+ // The firmware rejects DMShell packets that are not PKI-encrypted.
+ channel = DataPacket.PKC_CHANNEL_INDEX,
+ )
+ commandSender.sendData(packet)
+ }
+ }
+
+ // endregion
+
+ // region --- Helpers ---
+
+ private fun appendOutput(line: String) {
+ _outputLines.update { current -> (current + line).takeLast(MAX_OUTPUT_LINES) }
+ }
+
+ // endregion
+
+ override fun onCleared() {
+ super.onCleared()
+ heartbeatJob?.cancel()
+ if (_sessionState.value == SessionState.OPEN) closeSession()
+ Logger.d { "RemoteShellViewModel cleared for destNum=$destNum" }
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/ScanlinesOverlay.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/ScanlinesOverlay.kt
new file mode 100644
index 000000000..6753c815d
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/ScanlinesOverlay.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+
+/** Pitch between scanlines in pixels (one dark line every N px). */
+@Suppress("MagicNumber")
+private const val SCANLINE_PITCH_PX = 2f
+
+/** Opacity of each dark scanline stripe. Higher = more pronounced CRT effect. */
+@Suppress("MagicNumber")
+private const val SCANLINE_ALPHA = 0.18f
+
+/**
+ * Draws horizontal semi-transparent dark stripes over its entire bounds to simulate the inter-scan dark bands of a CRT
+ * phosphor screen.
+ *
+ * Render this as a transparent overlay on top of [TerminalCanvas]. The [flickerAlpha] modulates the stripe opacity so
+ * the scanlines breathe in sync with the phosphor flicker.
+ *
+ * @param preset Active [PhosphorPreset] (used for scanline tint colour).
+ * @param flickerAlpha Animated alpha from [FlickerEffect]; modulates stripe visibility.
+ */
+@Suppress("MagicNumber")
+@Composable
+fun ScanlinesOverlay(preset: PhosphorPreset, flickerAlpha: Float, modifier: Modifier = Modifier) {
+ Canvas(modifier = modifier.fillMaxSize()) {
+ val stripeColor = preset.bg.copy(alpha = SCANLINE_ALPHA * flickerAlpha)
+ var y = 0f
+ while (y < size.height) {
+ drawLine(color = stripeColor, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = 1f)
+ y += SCANLINE_PITCH_PX
+ }
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalCanvas.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalCanvas.kt
new file mode 100644
index 000000000..8f006721e
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalCanvas.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.sp
+
+/** Extra vertical gap between lines to mimic CRT inter-scan spacing (px). */
+@Suppress("MagicNumber")
+private const val LINE_SPACING_PX = 2f
+
+/** Terminal glyph size in SP. */
+@Suppress("MagicNumber")
+private const val TERMINAL_FONT_SIZE_SP = 13
+
+/**
+ * Draws [lines] of terminal output as a monospace character grid with a phosphor bloom effect.
+ *
+ * Each confirmed text row is rendered in two passes:
+ * 1. A glow pass (two translucent offset copies) using [PhosphorPreset.glow] to produce the phosphor halo.
+ * 2. A sharp pass with [PhosphorPreset.fg] for crisp readable glyphs on top.
+ *
+ * The last row additionally renders any [pendingInput] text as a dim suffix using [PhosphorPreset.dim]. Pending
+ * characters are keystrokes that have been typed but not yet flushed to the mesh — they appear immediately for
+ * responsiveness but at reduced brightness to signal their "in-flight" status. On flush [pendingInput] is `""` and the
+ * dim suffix disappears instantly.
+ *
+ * Only Compose/KMP APIs are used here — no `android.*` imports.
+ *
+ * @param lines Ordered confirmed output lines (oldest first).
+ * @param pendingInput Unflushed keystrokes to render as a dim suffix on the last line.
+ * @param preset Active [PhosphorPreset].
+ * @param flickerAlpha Animated alpha from [FlickerEffect].
+ * @param showCursor Whether to render the blinking block cursor after [pendingInput].
+ */
+@Suppress("LongMethod", "MagicNumber")
+@Composable
+fun TerminalCanvas(
+ lines: List,
+ pendingInput: String,
+ preset: PhosphorPreset,
+ flickerAlpha: Float,
+ showCursor: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val measurer = rememberTextMeasurer()
+
+ val baseStyle =
+ remember(preset) { TextStyle(fontFamily = FontFamily.Monospace, fontSize = TERMINAL_FONT_SIZE_SP.sp) }
+ val fgStyle = remember(preset) { baseStyle.copy(color = preset.fg) }
+ val glowStyle = remember(preset) { baseStyle.copy(color = preset.glow) }
+ val dimStyle = remember(preset) { baseStyle.copy(color = preset.dim) }
+
+ val charWidthPx = remember(preset) { measurer.measure("M", fgStyle).size.width.toFloat() }
+
+ Canvas(modifier = modifier.fillMaxSize()) {
+ drawRect(color = preset.bg)
+
+ val fontSizePx = TERMINAL_FONT_SIZE_SP.sp.toPx()
+ val lineHeightPx = fontSizePx + LINE_SPACING_PX
+
+ val visibleLines = (size.height / lineHeightPx).toInt().coerceAtLeast(1)
+ val displayLines = lines.takeLast(visibleLines)
+
+ displayLines.forEachIndexed { index, line ->
+ val y = index * lineHeightPx
+ val isLastLine = index == displayLines.lastIndex
+
+ // --- Glow pass ---
+ drawText(
+ textMeasurer = measurer,
+ text = line,
+ style = glowStyle.copy(color = preset.glow.copy(alpha = flickerAlpha * 0.6f)),
+ topLeft = Offset(-1f, y - 1f),
+ )
+ drawText(
+ textMeasurer = measurer,
+ text = line,
+ style = glowStyle.copy(color = preset.glow.copy(alpha = flickerAlpha * 0.4f)),
+ topLeft = Offset(1f, y + 1f),
+ )
+
+ // --- Sharp foreground pass ---
+ drawText(
+ textMeasurer = measurer,
+ text = line,
+ style = fgStyle.copy(color = preset.fg.copy(alpha = flickerAlpha)),
+ topLeft = Offset(0f, y),
+ )
+
+ // --- Pending input suffix (last line only) ---
+ if (isLastLine && pendingInput.isNotEmpty()) {
+ val confirmedWidthPx = measurer.measure(line, fgStyle).size.width.toFloat()
+ drawText(
+ textMeasurer = measurer,
+ text = pendingInput,
+ style = dimStyle.copy(color = preset.dim.copy(alpha = flickerAlpha)),
+ topLeft = Offset(confirmedWidthPx, y),
+ )
+ }
+ }
+
+ // If there are no confirmed lines yet but there is pending input, draw it on row 0.
+ if (displayLines.isEmpty() && pendingInput.isNotEmpty()) {
+ drawText(
+ textMeasurer = measurer,
+ text = pendingInput,
+ style = dimStyle.copy(color = preset.dim.copy(alpha = flickerAlpha)),
+ topLeft = Offset.Zero,
+ )
+ }
+
+ // --- Block cursor (positioned after pending input, or after the last confirmed line) ---
+ if (showCursor) {
+ val lastConfirmed = displayLines.lastOrNull() ?: ""
+ val cursorRow = displayLines.lastIndex.coerceAtLeast(0)
+ val confirmedWidth =
+ if (lastConfirmed.isNotEmpty()) {
+ measurer.measure(lastConfirmed, fgStyle).size.width.toFloat()
+ } else {
+ 0f
+ }
+ val pendingWidth =
+ if (pendingInput.isNotEmpty()) {
+ measurer.measure(pendingInput, dimStyle).size.width.toFloat()
+ } else {
+ 0f
+ }
+ val cursorX = confirmedWidth + pendingWidth
+ val cursorY = cursorRow * lineHeightPx
+ drawRect(
+ color = preset.fg.copy(alpha = flickerAlpha),
+ topLeft = Offset(cursorX, cursorY),
+ size = Size(charWidthPx, lineHeightPx - LINE_SPACING_PX),
+ )
+ }
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index 233942f00..1debb9f3f 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -46,10 +46,12 @@ import org.meshtastic.core.resources.ic_memory
import org.meshtastic.core.resources.ic_perm_scan_wifi
import org.meshtastic.core.resources.ic_power
import org.meshtastic.core.resources.ic_router
+import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.power
+import org.meshtastic.core.resources.remote_shell
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
@@ -66,6 +68,8 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
+import org.meshtastic.feature.node.metrics.terminal.RemoteShellScreen
+import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel
import kotlin.reflect.KClass
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -147,6 +151,15 @@ fun EntryProviderScope.nodeDetailGraph(
tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() }
}
+ // RemoteShell uses its own ViewModel and is wired up separately from the MetricsViewModel-based screens.
+ entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
+ val remoteShellViewModel = koinViewModel { parametersOf(args.destNum) }
+ RemoteShellScreen(
+ viewModel = remoteShellViewModel,
+ onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
+ )
+ }
+
NodeDetailScreen.entries.forEach { routeInfo ->
when (routeInfo.routeClass) {
NodeDetailRoute.DeviceMetrics::class ->
@@ -165,6 +178,7 @@ fun EntryProviderScope.nodeDetailGraph(
addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum }
NodeDetailRoute.NeighborInfoLog::class ->
addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum }
+ // NodeDetailRoute.RemoteShell is handled by the dedicated entry above.
else -> Unit
}
}
@@ -248,4 +262,12 @@ enum class NodeDetailScreen(
Res.drawable.ic_group,
{ metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
),
+ REMOTE_SHELL(
+ Res.string.remote_shell,
+ NodeDetailRoute.RemoteShell::class,
+ Res.drawable.ic_terminal,
+ // Navigation for RemoteShell is handled by a dedicated entry
+ // block in nodeDetailGraph() that resolves RemoteShellViewModel instead of MetricsViewModel.
+ { _, _ -> },
+ ),
}
diff --git a/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt
new file mode 100644
index 000000000..828dd1ad9
--- /dev/null
+++ b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/** iOS actual — CRT curvature is a no-op; AGSL is Android-only. */
+@Composable actual fun Modifier.crtCurvature(strength: Float): Modifier = this
diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt
new file mode 100644
index 000000000..c31a9c44d
--- /dev/null
+++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics.terminal
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/** JVM (desktop/test) actual — CRT curvature is a no-op; AGSL is Android-only. */
+@Composable actual fun Modifier.crtCurvature(strength: Float): Modifier = this