feat(remote-shell): mesh terminal for the firmware DMShell module (#6862)

This commit is contained in:
James Rich authored and GitHub committed 2026-10-02 20:16:57 +00:00
1 parent 5fc82d5e67
commit 455083c246
33 files changed
+3853 -54

No files matched your search

+39
View File
@@ -1494,6 +1494,45 @@ remote_admin_unreachable
remote_hardware
remote_hardware_config
remote_hardware_enabled
remote_shell
remote_shell_command_hint
remote_shell_error_notice
remote_shell_full_screen
remote_shell_input_dropped
remote_shell_key_alt
remote_shell_key_ctrl
remote_shell_key_down
remote_shell_key_end
remote_shell_key_escape
remote_shell_key_home
remote_shell_key_left
remote_shell_key_page_down
remote_shell_key_page_up
remote_shell_key_right
remote_shell_key_tab
remote_shell_key_up
remote_shell_last_contact
remote_shell_menu_line_mode
remote_shell_menu_paste
remote_shell_menu_text_larger
remote_shell_menu_text_smaller
remote_shell_modifier_locked
remote_shell_modifier_once
remote_shell_more_options
remote_shell_no_reply
remote_shell_no_reply_reason
remote_shell_open
remote_shell_open_description
remote_shell_reconnect
remote_shell_send
remote_shell_session_closed
remote_shell_session_closed_reason
remote_shell_status_closed
remote_shell_status_closing
remote_shell_status_failed
remote_shell_status_not_connected
remote_shell_status_opening
remote_shell_subtitle_round_trip
remotely_administrating
### REMOVE ###
remove
@@ -64,6 +64,7 @@ import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
@@ -110,6 +111,7 @@ class MeshDataHandlerImpl(
private val storeForwardHandler: StoreForwardPacketHandler,
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
private val remoteShellHandler: RemoteShellHandler,
private val collectorRegistry: DiscoveryPacketCollectorRegistry,
private val geofenceMonitor: GeofenceMonitor,
private val meshBeaconRepository: MeshBeaconRepository,
@@ -199,6 +201,10 @@ class MeshDataHandlerImpl(
adminPacketHandler.handleAdminMessage(packet, myNodeNum, session)
}
PortNum.REMOTE_SHELL_APP -> {
remoteShellHandler.handleRemoteShell(packet)
}
PortNum.NEIGHBORINFO_APP -> {
neighborInfoHandler.handleNeighborInfo(packet)
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
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
/**
* Decodes incoming [RemoteShell] frames (REMOTE_SHELL_APP) and hands them on; session state lives with the terminal
* screen.
*/
@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<ReceivedShellFrame>(extraBufferCapacity = 16)
override val lastFrame: SharedFlow<ReceivedShellFrame> = _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))
}
}
@@ -61,6 +61,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.repository.RadioSessionLease
import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
@@ -105,6 +106,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 radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val session = RadioSessionContext(generation = 7L, address = "tcp:test")
@@ -164,6 +166,7 @@ class MeshDataHandlerTest {
storeForwardHandler = storeForwardHandler,
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
remoteShellHandler = remoteShellHandler,
collectorRegistry = mock(MockMode.autofill),
// GeofenceMonitor is a final @Single (mokkery can't mock it) — use a real one over mocked
// collaborators. With no geofence-bearing waypoints emitted, onPositionReceived is a no-op.
@@ -829,8 +832,7 @@ class MeshDataHandlerTest {
@Test
fun `a genuine proof is still recorded after a forged ack has settled the packet`() = testScope.runTest {
val updates = mutableListOf<DataPacket>()
val forged =
sentPacket(MeshPacket.AckProofStatus.ACK_PROOF_INVALID.value).copy(status = MessageStatus.RECEIVED)
val forged = sentPacket(MeshPacket.AckProofStatus.ACK_PROOF_INVALID.value).copy(status = MessageStatus.RECEIVED)
everySuspend { packetRepository.findPacketsWithId(99) } returns listOf(forged)
everySuspend { packetRepository.update(any(), any()) } calls { call -> updates.add(call.arg(0)) }
@@ -67,6 +67,12 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
val supportsQrCodeSharing = atLeast(V2_6_8)
/**
* Support for the DMShell module (a PTY over the mesh, REMOTE_SHELL_APP). Firmware v2.8.2 is the first to carry it,
* and only Linux `meshtasticd` builds compile it in, which the version alone cannot tell.
*/
val supportsRemoteShell = atLeast(V2_8_2)
/** Support for the Status Message module, from the `since_firmware` its `ModuleConfig` field declares. */
val supportsStatusMessage = offers(ModuleConfig.statusmessage)
@@ -149,6 +155,7 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
private val V2_7_12 = DeviceVersion("2.7.12")
private val V2_7_18 = DeviceVersion("2.7.18")
private val V2_8_0 = DeviceVersion("2.8.0")
private val V2_8_2 = DeviceVersion("2.8.2")
private val UNRELEASED = DeviceVersion("9.9.9")
}
}
@@ -127,6 +127,13 @@ class CapabilitiesTest {
assertTrue(caps("2.8.0").supportsTakConfig)
}
@Test
fun supportsRemoteShell_requires_V2_8_2() {
assertFalse(caps("2.8.1").supportsRemoteShell)
assertTrue(caps("2.8.2").supportsRemoteShell)
assertTrue(caps("2.8.2.977b1d7").supportsRemoteShell)
}
@Test
fun supportsEsp32Ota_requires_V2_7_18() {
assertFalse(caps("2.7.17").supportsEsp32Ota)
@@ -209,6 +216,7 @@ class CapabilitiesTest {
assertFalse(c.supportsSecondaryChannelLocation)
assertFalse(c.supportsStatusMessage)
assertFalse(c.supportsTakConfig)
assertFalse(c.supportsRemoteShell)
assertFalse(c.supportsEsp32Ota)
}
@@ -219,5 +227,6 @@ class CapabilitiesTest {
assertTrue(c.canSendVerifiedContacts)
assertTrue(c.supportsStatusMessage)
assertTrue(c.supportsTakConfig)
assertTrue(c.supportsRemoteShell)
}
}
@@ -88,6 +88,8 @@ sealed interface NodeDetailRoute : Route {
@Serializable data class AirQualityMetrics(val destNum: Int) : NodeDetailRoute
@Serializable data class NeighborInfoLog(val destNum: Int) : NodeDetailRoute
@Serializable data class RemoteShell(val destNum: Int) : NodeDetailRoute
}
@Serializable
@@ -0,0 +1,51 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
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, served by the firmware's
* DMShell module (see [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<ReceivedShellFrame>
/**
* Processes an incoming RemoteShell packet.
*
* @param packet The received mesh packet carrying a [RemoteShell] payload.
*/
fun handleRemoteShell(packet: MeshPacket)
}
@@ -516,7 +516,7 @@
<string name="doc_keywords_messages">message,channel,encryption,direct,broadcast,quick-chat</string>
<string name="doc_keywords_mqtt">mqtt,broker,internet,bridge,uplink,downlink</string>
<string name="doc_keywords_node_metrics">metrics,telemetry,signal,snr,rssi,battery,traceroute</string>
<string name="doc_keywords_nodes">node,mesh,list,role,status,favorite,filter</string>
<string name="doc_keywords_nodes">node,mesh,list,role,status,favorite,filter,shell,terminal</string>
<string name="doc_keywords_notifications">notification,alert,sound,mute,reply,watch,wear os</string>
<string name="doc_keywords_onboarding">setup,welcome,permissions,first-launch</string>
<string name="doc_keywords_settings_module">module,serial,telemetry,canned,store-forward,administration</string>
@@ -1554,6 +1554,45 @@
<string name="remote_hardware">Remote Hardware</string>
<string name="remote_hardware_config">Remote Hardware Config</string>
<string name="remote_hardware_enabled">Remote Hardware enabled</string>
<string name="remote_shell">Remote Shell</string>
<string name="remote_shell_command_hint">Command</string>
<string name="remote_shell_error_notice">Error from the node: %1$s</string>
<string name="remote_shell_full_screen">Full-screen programs need more than the mesh can carry, so their output may not display correctly</string>
<string name="remote_shell_input_dropped">%1$d bytes of input dropped, the node is not keeping up</string>
<string name="remote_shell_key_alt">Alt</string>
<string name="remote_shell_key_ctrl">Control</string>
<string name="remote_shell_key_down">Down arrow</string>
<string name="remote_shell_key_end">End</string>
<string name="remote_shell_key_escape">Escape</string>
<string name="remote_shell_key_home">Home</string>
<string name="remote_shell_key_left">Left arrow</string>
<string name="remote_shell_key_page_down">Page down</string>
<string name="remote_shell_key_page_up">Page up</string>
<string name="remote_shell_key_right">Right arrow</string>
<string name="remote_shell_key_tab">Tab</string>
<string name="remote_shell_key_up">Up arrow</string>
<string name="remote_shell_last_contact">Waiting for the node, last heard %1$d s ago</string>
<string name="remote_shell_menu_line_mode">Line mode</string>
<string name="remote_shell_menu_paste">Paste</string>
<string name="remote_shell_menu_text_larger">Larger text</string>
<string name="remote_shell_menu_text_smaller">Smaller text</string>
<string name="remote_shell_modifier_locked">Locked</string>
<string name="remote_shell_modifier_once">Applies to the next key</string>
<string name="remote_shell_more_options">More options</string>
<string name="remote_shell_no_reply">No reply from the node. It must list your radio's public key as an admin key, and be in range.</string>
<string name="remote_shell_no_reply_reason">Could not open the session (%1$s). The node must list your radio's public key as an admin key, and be in range.</string>
<string name="remote_shell_open">Open terminal</string>
<string name="remote_shell_open_description">Opens a shell on this node, starting a remote admin session first if needed</string>
<string name="remote_shell_reconnect">Reconnect</string>
<string name="remote_shell_send">Send</string>
<string name="remote_shell_session_closed">Session closed</string>
<string name="remote_shell_session_closed_reason">Session closed: %1$s</string>
<string name="remote_shell_status_closed">Session closed</string>
<string name="remote_shell_status_closing">Closing…</string>
<string name="remote_shell_status_failed">Session failed</string>
<string name="remote_shell_status_not_connected">Not connected</string>
<string name="remote_shell_status_opening">Opening session…</string>
<string name="remote_shell_subtitle_round_trip">%1$s, %2$d ms round trip</string>
<string name="remotely_administrating">"[Remote] %1$s"</string>
<!-- REMOVE -->
<string name="remove">Remove</string>
@@ -30,3 +30,5 @@ actual fun createClipEntry(text: String, label: String, sensitive: Boolean): Cli
}
return ClipEntry(clip)
}
actual fun ClipEntry.plainText(): String? = clipData.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text?.toString()
@@ -26,3 +26,6 @@ import androidx.compose.ui.platform.ClipEntry
* treat it as secret. Pass it for private keys and for channel URLs, which carry channel PSKs.
*/
expect fun createClipEntry(text: String, label: String = "", sensitive: Boolean = false): ClipEntry
/** The plain text an entry carries, or null when it holds none (an image, a file, an empty clip). */
expect fun ClipEntry.plainText(): String?
@@ -27,6 +27,8 @@ import org.meshtastic.core.common.util.CommonUri
actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry =
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
actual fun ClipEntry.plainText(): String? = null
actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html)
@Composable actual fun rememberOpenNfcSettings(): () -> Unit = {}
@@ -17,9 +17,17 @@
package org.meshtastic.core.ui.util
import androidx.compose.ui.platform.ClipEntry
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
// `sensitive` has no AWT equivalent — the desktop clipboard carries no such flag — so it is accepted and ignored.
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry =
ClipEntry(StringSelection(text))
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
actual fun ClipEntry.plainText(): String? = (nativeClipEntry as? Transferable)
?.takeIf { it.isDataFlavorSupported(DataFlavor.stringFlavor) }
// Another application can change or release the clipboard between the check and the read.
?.let { runCatching { it.getTransferData(DataFlavor.stringFlavor) as? String }.getOrNull() }
+17 -1
View File
@@ -2,13 +2,15 @@
title: Nodes
parent: User Guide
nav_order: 4
last_updated: 2026-09-19
last_updated: 2026-10-02
description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions.
aliases:
- node-list
- mesh-nodes
- peers
- hop-histogram
- remote-shell
- terminal
---
# Nodes
@@ -213,6 +215,20 @@ A full, browsable directory of every link is also available at **Settings → De
Some of these are affiliate links. Both places say so above the links: product links may be affiliate links, and purchases may earn Meshtastic a commission.
### Remote shell
**Open terminal**, in the node's **Administration** card, opens a shell on a Linux node running `meshtasticd` 2.8.2 or later, carried over the mesh. The node accepts it only from a radio whose public key is in its **Security → Admin key** list, the same list remote administration uses; the app starts that admin session first if there isn't one, and the card's title shows whether it is active. The terminal takes the whole window. If the node never answers, the screen says so after a minute.
The mesh carries a few hundred bytes a second, and the terminal is built around that:
- Once the node has echoed the first character of a line, what you type appears **underlined** at the cursor until the node echoes it back. At a prompt that doesn't echo, such as a password, nothing you type is shown; in line mode the command box masks its text and keeps it out of history.
- The subtitle shows the measured round trip. When the node stops answering, a line at the top of the terminal says how long ago it was last heard.
- The two rows of extra keys give **ESC**, **TAB**, arrows, **HOME**, **END**, **PGUP** and **PGDN**. **CTRL** and **ALT** are sticky: tap once to apply to the next key, twice to lock, a third time to release. **CTRL** then **c** interrupts a running command. A hardware keyboard's Ctrl and Alt work as usual.
- **Line mode**, in the **⋮** menu, gives you a command box: each command goes out in one transmission, **↑** and **↓** recall earlier commands without asking the node, and chips offer common commands for a `meshtasticd` host. It is the cheapest way to use the shell on a busy mesh.
- The **⋮** menu also pastes from the clipboard and changes the text size.
Colour, progress lines and `clear` display as they would in a desktop terminal. Full-screen programs such as `vi` or `top` redraw the whole screen for each key, which the mesh cannot carry; the terminal shows a notice instead.
## When no nodes appear
The list stays empty until your node hears another node.
@@ -290,7 +290,7 @@ class DefaultDocBundleLoader : DocBundleLoader {
CoreRes.string.doc_keywords_nodes,
"en/user/nodes.html",
4,
listOf("node-list", "mesh-nodes", "peers", "hop-histogram"),
listOf("node-list", "mesh-nodes", "peers", "hop-histogram", "remote-shell", "terminal"),
3800,
"nodes",
),
@@ -33,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.koin.compose.koinInject
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.FirmwareRelease
@@ -46,14 +47,16 @@ import org.meshtastic.core.resources.connect_radio_for_remote_admin
import org.meshtastic.core.resources.establishing_session
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.refresh_metadata
import org.meshtastic.core.resources.remote_admin
import org.meshtastic.core.resources.remote_shell_open
import org.meshtastic.core.resources.remote_shell_open_description
import org.meshtastic.core.resources.session_active
import org.meshtastic.core.resources.session_refresh_required
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.ForkLeft
import org.meshtastic.core.ui.icon.Icecream
@@ -67,6 +70,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.HardwareModel
@Composable
fun AdministrationSection(
@@ -79,7 +83,15 @@ fun AdministrationSection(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(24.dp)) {
SectionCard(title = Res.string.administration) {
SectionCard(
title = Res.string.administration,
titleTrailing =
if (metricsState.isLocal) {
null
} else {
{ SessionChip(node.num, sessionStatus, isEnsuringSession, onAction) }
},
) {
Column {
// Local nodes don't need a session — they short-circuit straight to the settings screen.
if (metricsState.isLocal) {
@@ -96,6 +108,24 @@ fun AdministrationSection(
onAction = onAction,
)
// The node gates the shell on the same admin_key list as remote admin, so the row
// establishes that session first, as the remote-admin row does. Only meshtasticd builds the
// module, and the firmware advertises it nowhere else. A node not yet asked for its metadata
// still gets the row: the tap's session check fetches it, and an older node says so on OPEN.
val firmwareKnown = !node.metadata?.firmware_version.isNullOrEmpty()
val offersShell = !firmwareKnown || node.capabilities.supportsRemoteShell
if (node.user.hw_model == HardwareModel.PORTDUINO && offersShell) {
SectionDivider()
ListItem(
text = stringResource(Res.string.remote_shell_open),
leadingIcon = vectorResource(Res.drawable.ic_terminal),
supportingText = stringResource(Res.string.remote_shell_open_description),
enabled = !isEnsuringSession,
onClick = { onAction(NodeDetailAction.OpenRemoteShell(node.num)) },
)
}
SectionDivider()
ListItem(
@@ -118,10 +148,9 @@ fun AdministrationSection(
}
/**
* Single primary affordance for opening the remote-admin screen. Replaces the prior two-row, no-feedback flow that
* required the user to know they had to tap "Metadata" first to populate `node.metadata` before "Remote Administration"
* un-greyed out. The session passkey freshness — not the metadata insert — is the real gate (see
* `firmware/src/modules/AdminModule.cpp:1460-1481`), and is now reflected via an [AssistChip] + inline progress.
* The session passkey freshness, not the metadata insert, is the real gate (see
* `firmware/src/modules/AdminModule.cpp:1460-1481`). Every row in the card that needs it shares one session, so its
* state sits in the card's title row as a [SessionChip], and this row shows inline progress while it is established.
*/
@Composable
private fun RemoteAdminListItem(
@@ -136,38 +165,13 @@ private fun RemoteAdminListItem(
is SessionStatus.Active -> null
is SessionStatus.Stale -> Res.string.session_refresh_required
}
val chipLabelRes =
when (sessionStatus) {
SessionStatus.NoSession -> null
is SessionStatus.Active -> Res.string.session_active
is SessionStatus.Stale -> Res.string.session_refresh_required
}
Column {
BasicListItem(
ListItem(
text = stringResource(Res.string.remote_admin),
leadingIcon = MeshtasticIcons.Settings,
supportingText = supportingTextRes?.let { stringResource(it) },
enabled = !isEnsuringSession,
trailingContent =
chipLabelRes?.let { res ->
{
AssistChip(
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) },
label = { androidx.compose.material3.Text(stringResource(res)) },
enabled = !isEnsuringSession,
colors =
if (sessionStatus is SessionStatus.Active) {
AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.onPrimaryContainer,
containerColor = MaterialTheme.colorScheme.primaryContainer,
)
} else {
AssistChipDefaults.assistChipColors()
},
)
}
},
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) },
)
AnimatedVisibility(visible = isEnsuringSession) {
@@ -184,6 +188,36 @@ private fun RemoteAdminListItem(
}
}
/** Nothing to show before a session exists; the remote-admin row's supporting text covers that case. */
@Composable
private fun SessionChip(
nodeNum: Int,
sessionStatus: SessionStatus,
isEnsuringSession: Boolean,
onAction: (NodeDetailAction) -> Unit,
) {
val labelRes =
when (sessionStatus) {
SessionStatus.NoSession -> return
is SessionStatus.Active -> Res.string.session_active
is SessionStatus.Stale -> Res.string.session_refresh_required
}
AssistChip(
onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) },
label = { androidx.compose.material3.Text(stringResource(labelRes)) },
enabled = !isEnsuringSession,
colors =
if (sessionStatus is SessionStatus.Active) {
AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.onPrimaryContainer,
containerColor = MaterialTheme.colorScheme.primaryContainer,
)
} else {
AssistChipDefaults.assistChipColors()
},
)
}
@Composable
private fun FirmwareSection(
metricsState: MetricsState,
@@ -62,6 +62,7 @@ import org.meshtastic.core.ui.util.createClipEntry
internal fun SectionCard(
title: StringResource,
modifier: Modifier = Modifier,
titleTrailing: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
ElevatedCard(
@@ -70,16 +71,22 @@ internal fun SectionCard(
shape = MaterialTheme.shapes.extraLarge,
) {
Column(modifier = Modifier.padding(vertical = 16.dp)) {
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier =
Modifier.padding(horizontal = 20.dp, vertical = 8.dp).semantics {
heading()
}, // Proper navigation for screen reader users
)
Row(
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier =
Modifier.weight(1f).padding(vertical = 8.dp).semantics {
heading()
}, // Proper navigation for screen reader users
)
titleTrailing?.invoke()
}
content()
}
}
@@ -40,6 +40,8 @@ internal fun handleNodeAction(
is NodeDetailAction.OpenRemoteAdmin -> viewModel.openRemoteAdmin(action.nodeNum)
is NodeDetailAction.OpenRemoteShell -> viewModel.openRemoteShell(action.nodeNum)
is NodeDetailAction.RefreshMetadata -> viewModel.refreshMetadata(action.nodeNum)
is NodeDetailAction.HandleNodeMenuAction -> {
@@ -38,6 +38,7 @@ import org.meshtastic.core.model.ContactKey
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.navigation.NodeDetailRoute
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.repository.LocalNodeUnavailableException
@@ -114,10 +115,9 @@ class NodeDetailViewModel(
private val isEnsuringSession = MutableStateFlow(false)
private val sessionStatusFlow =
activeNodeId.flatMapLatest { nodeId ->
if (nodeId == null) flowOf(SessionStatus.NoSession) else observeRemoteAdminSessionStatus(nodeId)
}
private val sessionStatusFlow = activeNodeId.flatMapLatest { nodeId ->
if (nodeId == null) flowOf(SessionStatus.NoSession) else observeRemoteAdminSessionStatus(nodeId)
}
/** One-shot navigation events from session-bearing actions (e.g. successful remote-admin opens). */
private val _navigationEvents = Channel<Route>(capacity = Channel.BUFFERED)
@@ -225,15 +225,25 @@ class NodeDetailViewModel(
* Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a
* snackbar with the appropriate guidance on [EnsureSessionResult.Disconnected] or [EnsureSessionResult.Timeout].
*/
fun openRemoteAdmin(destNum: Int) {
fun openRemoteAdmin(destNum: Int) =
ensureSessionThenNavigate(destNum, "openRemoteAdmin") { SettingsRoute.Settings(destNum) }
/**
* The shell is gated by the node's `security.admin_key` list, the same list the admin passkey exchange goes
* through, so it needs the identical session check - just a different destination.
*/
fun openRemoteShell(destNum: Int) =
ensureSessionThenNavigate(destNum, "openRemoteShell") { NodeDetailRoute.RemoteShell(destNum) }
private fun ensureSessionThenNavigate(destNum: Int, tag: String, destination: () -> Route) {
// Atomic check-and-flip prevents a double-tap from queuing two passkey exchanges + two navigation events.
if (!isEnsuringSession.compareAndSet(expect = false, update = true)) return
safeLaunch(tag = "openRemoteAdmin") {
safeLaunch(tag = tag) {
try {
when (ensureRemoteAdminSession(destNum)) {
EnsureSessionResult.AlreadyActive,
EnsureSessionResult.Refreshed,
-> _navigationEvents.trySend(SettingsRoute.Settings(destNum))
-> _navigationEvents.trySend(destination())
EnsureSessionResult.Disconnected -> {
val text = Res.string.connect_radio_for_remote_admin
@@ -0,0 +1,118 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
/** Floor on how long a prediction may wait for its echo, before a round trip has been measured. */
internal const val PREDICTION_TIMEOUT_MIN_MS = 4_000L
internal const val PREDICTION_TIMEOUT_MAX_MS = 20_000L
private const val PREDICTION_TIMEOUT_RTT_MULTIPLIER = 3
/** Prompts whose input the remote does not echo, so nothing typed into them may be drawn locally. */
private val SECRET_PROMPT = Regex("""(password|passphrase|passcode|\bpin\b)[^:]*:\s*$""", RegexOption.IGNORE_CASE)
/** Whether the text before the cursor reads as a prompt for a secret, such as sudo's. */
internal fun looksLikeSecretPrompt(lineBeforeCursor: String): Boolean = SECRET_PROMPT.containsMatchIn(lineBeforeCursor)
/**
* Predictive local echo in the manner of mosh: characters already sent are drawn at once, marked as unconfirmed, and
* retired as the remote echo arrives.
*
* Nothing is drawn on a line until the remote has echoed one of its characters, mosh's own rule: a prompt that does not
* echo, such as a password, never confirms one, so what is typed into it is never shown. Once confirmed, predictions
* are checked one character at a time against what the PTY prints. A different character - a completion, a redraw, a
* program reading keys itself - means the guess was wrong, so they are dropped rather than corrected and the line must
* confirm again; cursor movement and erases are how a shell echoes a backspace, so those pass. A prediction that waits
* longer than [timeoutMs] is dropped too, and prediction stays off until the next Enter. Only the line being typed is
* predicted; after Enter the next prompt is unknown.
*
* Not thread-safe, and owns no clock.
*/
internal class LocalEcho {
private val predicted = StringBuilder()
private var oldestSentMs = 0L
private var suppressed = false
private var echoConfirmed = false
/** Whether typing on this line may be drawn before the remote echoes it. */
val showsTyping: Boolean
get() = echoConfirmed && !suppressed
/** Characters sent but not yet echoed, to draw after the cursor; empty until the line has confirmed an echo. */
val pending: String
get() = if (showsTyping) predicted.toString() else ""
fun onSent(text: String, nowMs: Long) {
for (c in text) {
when {
c == '\r' || c == '\n' -> {
predicted.clear()
suppressed = false
echoConfirmed = false
}
c == '\u007f' || c == '\b' -> if (predicted.isNotEmpty()) predicted.deleteAt(predicted.lastIndex)
c.isISOControl() -> {
predicted.clear()
echoConfirmed = false
}
!suppressed -> {
if (predicted.isEmpty()) oldestSentMs = nowMs
predicted.append(c)
}
}
}
}
fun onOutput(events: List<EchoEvent>, nowMs: Long) {
for (event in events) {
if (predicted.isEmpty()) return
when (event) {
is EchoEvent.Printed ->
if (event.char == predicted[0]) {
predicted.deleteAt(0)
echoConfirmed = true
// The echo is flowing, so the rest of the line gets a fresh wait.
oldestSentMs = nowMs
} else {
predicted.clear()
echoConfirmed = false
}
// A backspace or CR is how a shell echoes an erase; anything else here is the shell rewriting the line.
EchoEvent.Edited -> Unit
}
}
}
/** Drops predictions that have waited too long, and keeps prediction off for the rest of the line. */
fun tick(nowMs: Long, roundTripMs: Long?) {
if (predicted.isEmpty()) return
if (nowMs - oldestSentMs >= timeoutMs(roundTripMs)) {
predicted.clear()
suppressed = true
echoConfirmed = false
}
}
private fun timeoutMs(roundTripMs: Long?): Long =
((roundTripMs ?: 0L) * PREDICTION_TIMEOUT_RTT_MULTIPLIER).coerceIn(
PREDICTION_TIMEOUT_MIN_MS,
PREDICTION_TIMEOUT_MAX_MS,
)
}
@@ -0,0 +1,541 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import okio.Buffer
import okio.ByteString
import org.meshtastic.proto.RemoteShell
/** Frames a peer may hold unacknowledged before it must wait; the firmware's `DEFAULT_TX_WINDOW_FRAMES`. */
internal const val INPUT_WINDOW_FRAMES = 4
/** In-order frames received with nothing sent back before a bare ACK is owed; the firmware's `ACK_AFTER_RX_FRAMES`. */
internal const val ACK_AFTER_FRAMES = 2
/** Repeats of one unacknowledged frame before the peer is declared gone; the firmware's bound too. */
internal const val MAX_RETRANSMITS = 25
internal const val MAX_INPUT_CHUNK_BYTES = 64
internal const val PENDING_INPUT_MAX_BYTES = 4096
internal const val TX_HISTORY_LEN = 50
internal const val REORDER_SLOTS = 8
internal const val RETRY_MIN_MS = 1_000L
internal const val RETRY_MAX_MS = 8_000L
internal const val RETRY_FLAT_ATTEMPTS = 4
private const val RETRANSMIT_LATENCY_MULTIPLIER = 2.0
private const val ACK_LATENCY_SMOOTHING = 0.25
internal const val OPEN_RETRY_FIRST_MS = 5_000L
internal const val OPEN_RETRY_MAX_MS = 30_000L
internal const val OPEN_TIMEOUT_MS = 60_000L
internal const val HEARTBEAT_IDLE_DELAY_MS = 5_000L
internal const val HEARTBEAT_REPEAT_MS = 15_000L
internal sealed interface ShellEvent {
data object Opened : ShellEvent
data class Output(val bytes: ByteString) : ShellEvent
/** A sequenced ERROR: the session survives it. */
data class RemoteError(val message: String) : ShellEvent
data class Closed(val reason: String) : ShellEvent
data class InputDropped(val bytes: Int) : ShellEvent
}
internal class ShellStep(val send: List<RemoteShell>, val events: List<ShellEvent>)
/** What the link knows about the path to the node, for the UI to show a slow link as slow rather than broken. */
data class LinkHealth(
/** Smoothed time for a frame of ours to be acknowledged, or null before the first measurement. */
val roundTripMs: Long? = null,
/** Frames of ours the node has not acknowledged yet. */
val unacknowledged: Int = 0,
/** When anything last arrived from the node. */
val lastInboundMs: Long = 0L,
/** When the oldest unacknowledged frame of ours was last sent, or null when the node is level with us. */
val waitingSinceMs: Long? = null,
)
internal fun shellFrame(op: RemoteShell.OpCode, configure: (RemoteShell.Builder) -> Unit = {}): RemoteShell =
RemoteShell.Builder()
.also { wb ->
wb.op = op
configure(wb)
}
.build()
/**
* The client half of the firmware DMShell reliability layer, as `bin/dmshell_client.py` implements it.
*
* Every sequenced frame carries our receive cursor in `ack_seq`; the peer's cursor over our frames is the larger of
* `ack_seq` and `last_rx_seq` on anything it sends. Either side holds at most [INPUT_WINDOW_FRAMES] unacknowledged
* frames, so a stream only keeps flowing while the receiver answers it - hence the bare ACK every [ACK_AFTER_FRAMES]
* in-order frames and on every duplicate. An ACK with `last_rx_seq` N > 0 asks for a replay from N + 1.
*
* Not thread-safe, and owns no clock: callers serialise access and pass `nowMs`.
*/
@Suppress("TooManyFunctions")
internal class RemoteShellLink(val sessionId: Int, private val inputWindowFrames: Int = INPUT_WINDOW_FRAMES) {
private class Sent(val frame: RemoteShell, var sentMs: Long)
var isOpen = false
private set
var isClosed = false
private set
private var nextTxSeq = 1
private val txHistory = ArrayDeque<Sent>()
private var peerAcked = 0
private var lastRxSeq = 0
private var nextExpectedRxSeq = 1
private var highestSeenRxSeq = 0
private val pendingRx = mutableMapOf<Int, RemoteShell>()
private var framesSinceOutbound = 0
private var lastRequestedMissingSeq = 0
private var lastMissingRequestMs = 0L
private var missingRequestIntervalMs = RETRY_MIN_MS
private var missingRequestAttempts = 0
private var ackLatencyMs: Double? = null
private var retransmitSeq = 0
private var retransmits = 0
private var retransmitIntervalMs = RETRY_MIN_MS
private var nextRetransmitMs = 0L
private val pendingInput = Buffer()
private var lastInboundMs = 0L
private var lastHeartbeatMs = 0L
private var openSeq = 0
private var openDeadlineMs = 0L
private var nextOpenRetryMs = 0L
private var openRetryIntervalMs = OPEN_RETRY_FIRST_MS
private val out = mutableListOf<RemoteShell>()
private val events = mutableListOf<ShellEvent>()
val health: LinkHealth
get() =
LinkHealth(
roundTripMs = ackLatencyMs?.toLong(),
unacknowledged = highestSentSeq() - peerAcked,
lastInboundMs = lastInboundMs,
waitingSinceMs = txHistory.firstOrNull { it.frame.seq == peerAcked + 1 }?.sentMs,
)
// region --- Commands ---
fun open(cols: Int, rows: Int, nowMs: Long): ShellStep = step {
lastInboundMs = nowMs
openDeadlineMs = nowMs + OPEN_TIMEOUT_MS
nextOpenRetryMs = nowMs + OPEN_RETRY_FIRST_MS
openSeq =
sendSequenced(
shellFrame(RemoteShell.OpCode.OPEN) { wb ->
wb.cols = cols
wb.rows = rows
},
nowMs,
)
}
/** Queue typed bytes and send what the window allows. Everything goes through the queue, so order holds. */
fun input(bytes: ByteString, nowMs: Long): ShellStep = step {
if (isClosed) return@step
val room = (PENDING_INPUT_MAX_BYTES - pendingInput.size).toInt().coerceAtLeast(0)
val kept = bytes.size.coerceAtMost(room)
pendingInput.write(bytes, 0, kept)
if (kept < bytes.size) events += ShellEvent.InputDropped(bytes.size - kept)
flushInput(nowMs)
}
fun resize(cols: Int, rows: Int, nowMs: Long): ShellStep = step {
if (isOpen && !isClosed) {
sendSequenced(
shellFrame(RemoteShell.OpCode.RESIZE) { wb ->
wb.cols = cols
wb.rows = rows
},
nowMs,
)
}
}
fun close(): ShellStep = step { if (!isClosed) closeLocally("") }
// endregion
// region --- Inbound ---
@Suppress("CyclomaticComplexMethod", "ReturnCount")
fun receive(frame: RemoteShell, nowMs: Long): ShellStep = step {
if (isClosed || frame.session_id != sessionId) return@step
lastInboundMs = nowMs
// Before any ordering: an out-of-order frame still carries a valid cursor, and during a gap it may be the
// only kind arriving.
notePeerCursor(maxOf(frame.ack_seq, frame.last_rx_seq), nowMs)
// Terminal, so not held behind a gap the peer has just said it cannot fill.
if (frame.op == RemoteShell.OpCode.CLOSED) {
isClosed = true
events += ShellEvent.Closed(frame.payload.utf8())
return@step
}
if (frame.op == RemoteShell.OpCode.ACK) {
if (frame.last_rx_seq > 0) replayFrom(frame.last_rx_seq + 1, nowMs)
flushInput(nowMs)
return@step
}
when (noteReceivedSeq(frame.seq)) {
RxAction.DUPLICATE -> {
// Already in order here, so the peer has not seen our cursor: a sender with its window shut.
sendAck(requestMissingSeqOnce(nowMs))
return@step
}
RxAction.GAP -> {
rememberOutOfOrder(frame)
requestMissingSeqOnce(nowMs)?.let { sendAck(it) }
return@step
}
RxAction.PROCESS -> Unit
}
handleInOrder(frame, nowMs)
drainPendingRx(nowMs)
if (isClosed) return@step
flushInput(nowMs)
val missing = requestMissingSeqOnce(nowMs)
if (missing != null) {
sendAck(missing)
} else if (framesSinceOutbound >= ACK_AFTER_FRAMES) {
sendAck(null)
}
}
private fun handleInOrder(frame: RemoteShell, nowMs: Long) {
when (frame.op) {
RemoteShell.OpCode.OPEN_OK ->
if (!isOpen) {
isOpen = true
events += ShellEvent.Opened
}
RemoteShell.OpCode.OUTPUT -> if (frame.payload.size > 0) events += ShellEvent.Output(frame.payload)
RemoteShell.OpCode.ERROR ->
if (frame.seq == 0) {
// Sessionless: the node has no session for us (open_failed, invalid_session).
isClosed = true
events += ShellEvent.Closed(frame.payload.utf8())
} else {
events += ShellEvent.RemoteError(frame.payload.utf8())
}
RemoteShell.OpCode.PONG -> {
if (frame.last_rx_seq != 0 && frame.last_rx_seq < highestSentSeq()) {
replayFrom(frame.last_rx_seq + 1, nowMs)
}
if (frame.last_tx_seq > lastRxSeq && frame.last_tx_seq > highestSeenRxSeq) {
highestSeenRxSeq = frame.last_tx_seq
}
}
else -> Unit
}
}
private fun drainPendingRx(nowMs: Long) {
while (!isClosed) {
val next = pendingRx.remove(nextExpectedRxSeq) ?: return
if (noteReceivedSeq(next.seq) != RxAction.PROCESS) {
rememberOutOfOrder(next)
return
}
handleInOrder(next, nowMs)
}
}
// endregion
// region --- Timers ---
/** Drive OPEN retries, a shut window, and the heartbeat. Call every few hundred milliseconds. */
fun tick(nowMs: Long): ShellStep = step {
if (isClosed) return@step
if (!isOpen) {
serviceOpen(nowMs)
return@step
}
flushInput(nowMs)
if (!windowOpen()) serviceShutWindow(nowMs)
if (!isClosed && heartbeatDue(nowMs)) {
lastHeartbeatMs = nowMs
sendSequenced(
shellFrame(RemoteShell.OpCode.PING) { wb ->
wb.last_tx_seq = highestSentSeq()
wb.last_rx_seq = lastRxSeq
},
nowMs,
)
}
}
private fun serviceOpen(nowMs: Long) {
if (nowMs >= openDeadlineMs) {
// The node may have opened a session and be streaming into it; CLOSE is acted on out of order.
closeLocally("no reply from the node")
return
}
if (nowMs < nextOpenRetryMs) return
// Same session id and seq, which is how the firmware tells a lost OPEN_OK from a new session.
txHistory.firstOrNull { it.frame.seq == openSeq }?.let { resend(it, nowMs) }
openRetryIntervalMs = (openRetryIntervalMs * 2).coerceAtMost(OPEN_RETRY_MAX_MS)
nextOpenRetryMs = nowMs + openRetryIntervalMs
}
/**
* With our window full the peer never sees a seq above the gap, so it never asks for the replay that would reopen
* us. Repeat the oldest unacknowledged frame instead, and give up after [MAX_RETRANSMITS].
*/
@Suppress("ReturnCount")
private fun serviceShutWindow(nowMs: Long) {
val missing = peerAcked + 1
if (missing > highestSentSeq() || nowMs < nextRetransmitMs) return
if (missing != retransmitSeq) {
retransmitSeq = missing
retransmits = 0
retransmitIntervalMs = baseIntervalMs()
// The wait belongs to the frame, not to the tick that noticed it.
val sentAt = txHistory.firstOrNull { it.frame.seq == missing }?.sentMs
if (sentAt != null && nowMs < sentAt + retransmitIntervalMs) {
nextRetransmitMs = sentAt + retransmitIntervalMs
return
}
}
if (retransmits >= MAX_RETRANSMITS) {
closeLocally("the node stopped answering")
return
}
retransmits++
if (retransmits > RETRY_FLAT_ATTEMPTS) {
retransmitIntervalMs = (retransmitIntervalMs * 2).coerceAtMost(RETRY_MAX_MS)
}
nextRetransmitMs = nowMs + retransmitIntervalMs
replayFrom(missing, nowMs)
}
/** Keyed on inbound silence alone, so typing into a stalled session cannot suppress its own recovery. */
private fun heartbeatDue(nowMs: Long): Boolean {
if (nowMs - lastInboundMs < HEARTBEAT_IDLE_DELAY_MS) return false
return lastHeartbeatMs <= lastInboundMs || nowMs - lastHeartbeatMs >= HEARTBEAT_REPEAT_MS
}
// endregion
// region --- Sending ---
private fun windowOpen(): Boolean = inputWindowFrames == 0 || highestSentSeq() - peerAcked < inputWindowFrames
private fun flushInput(nowMs: Long) {
while (isOpen && !isClosed && pendingInput.size > 0 && windowOpen()) {
val chunk = pendingInput.readByteString(pendingInput.size.coerceAtMost(MAX_INPUT_CHUNK_BYTES.toLong()))
sendSequenced(shellFrame(RemoteShell.OpCode.INPUT) { wb -> wb.payload = chunk }, nowMs)
}
}
private fun sendSequenced(template: RemoteShell, nowMs: Long): Int {
val seq = nextTxSeq++
val frame =
template
.newBuilder()
.also { wb ->
wb.session_id = sessionId
wb.seq = seq
wb.ack_seq = lastRxSeq
}
.build()
txHistory.addLast(Sent(frame, nowMs))
if (txHistory.size > TX_HISTORY_LEN) txHistory.removeFirst()
emit(frame)
return seq
}
/**
* [replayFrom] null is a bare ACK: `last_rx_seq` 0 is not a replay request, but `ack_seq` still moves the window.
*/
private fun sendAck(replayFrom: Int?) {
emit(
shellFrame(RemoteShell.OpCode.ACK) { wb ->
wb.session_id = sessionId
wb.seq = 0
wb.ack_seq = lastRxSeq
wb.last_rx_seq = replayFrom?.let { it - 1 } ?: 0
},
)
}
private fun replayFrom(startSeq: Int, nowMs: Long) {
val sent = txHistory.firstOrNull { it.frame.seq == startSeq }
if (sent != null) {
resend(sent, nowMs)
return
}
val oldest = txHistory.firstOrNull()?.frame?.seq ?: return
if (startSeq in 1..<oldest) {
// Aged out of our ring: the peer can never get past this hole and would ask until its idle timeout.
closeLocally("the node asked for input this phone no longer holds")
}
}
private fun resend(sent: Sent, nowMs: Long) {
sent.sentMs = nowMs
emit(sent.frame.newBuilder().also { wb -> wb.ack_seq = lastRxSeq }.build())
}
private fun closeLocally(reason: String) {
emit(
shellFrame(RemoteShell.OpCode.CLOSE) { wb ->
wb.session_id = sessionId
wb.ack_seq = lastRxSeq
},
)
isClosed = true
events += ShellEvent.Closed(reason)
}
/** Anything we send carries our receive cursor, so it settles the flow-control debt whatever its op. */
private fun emit(frame: RemoteShell) {
framesSinceOutbound = 0
out += frame
}
private fun highestSentSeq(): Int = nextTxSeq - 1
// endregion
// region --- Sequence bookkeeping ---
private enum class RxAction {
PROCESS,
GAP,
DUPLICATE,
}
@Suppress("ReturnCount")
private fun noteReceivedSeq(seq: Int): RxAction {
if (seq == 0) return RxAction.PROCESS
if (seq < nextExpectedRxSeq) {
return if (highestSeenRxSeq >= nextExpectedRxSeq) RxAction.GAP else RxAction.DUPLICATE
}
if (seq > nextExpectedRxSeq) {
if (seq > highestSeenRxSeq) highestSeenRxSeq = seq
return RxAction.GAP
}
lastRxSeq = seq
nextExpectedRxSeq = seq + 1
if (lastRequestedMissingSeq != 0 && nextExpectedRxSeq > lastRequestedMissingSeq) {
lastRequestedMissingSeq = 0
missingRequestIntervalMs = baseIntervalMs()
missingRequestAttempts = 0
}
if (seq > highestSeenRxSeq) highestSeenRxSeq = seq
if (highestSeenRxSeq < nextExpectedRxSeq) highestSeenRxSeq = 0
// Only an in-order frame moves the cursor the peer is waiting on.
framesSinceOutbound++
return RxAction.PROCESS
}
/** Lowest seqs are needed soonest, so when full the highest held is the one given up, and only for a lower one. */
private fun rememberOutOfOrder(frame: RemoteShell) {
if (frame.seq <= nextExpectedRxSeq || frame.seq in pendingRx) return
if (pendingRx.size < REORDER_SLOTS) {
pendingRx[frame.seq] = frame
} else {
val highest = pendingRx.keys.max()
if (frame.seq < highest) {
pendingRx.remove(highest)
pendingRx[frame.seq] = frame
}
}
if (frame.seq > highestSeenRxSeq) highestSeenRxSeq = frame.seq
}
@Suppress("ReturnCount")
private fun requestMissingSeqOnce(nowMs: Long): Int? {
if (highestSeenRxSeq < nextExpectedRxSeq) return null
val sameSeq = lastRequestedMissingSeq == nextExpectedRxSeq
if (sameSeq && nowMs - lastMissingRequestMs < missingRequestIntervalMs) return null
if (sameSeq) {
missingRequestAttempts++
if (missingRequestAttempts > RETRY_FLAT_ATTEMPTS) {
missingRequestIntervalMs = (missingRequestIntervalMs * 2).coerceAtMost(RETRY_MAX_MS)
}
} else {
missingRequestAttempts = 1
missingRequestIntervalMs = baseIntervalMs()
}
lastRequestedMissingSeq = nextExpectedRxSeq
lastMissingRequestMs = nowMs
return nextExpectedRxSeq
}
/** Monotone and clamped to what we have sent, so a stale or confused cursor can neither close nor overgrant. */
private fun notePeerCursor(rawCursor: Int, nowMs: Long) {
val cursor = rawCursor.coerceAtMost(highestSentSeq())
if (cursor <= peerAcked) return
txHistory
.firstOrNull { it.frame.seq == cursor }
?.let { sent ->
val sample = (nowMs - sent.sentMs).toDouble()
if (sample > 0) {
val current = ackLatencyMs
ackLatencyMs = if (current == null) sample else current + ACK_LATENCY_SMOOTHING * (sample - current)
}
}
peerAcked = cursor
nextRetransmitMs = 0L
retransmitSeq = 0
retransmits = 0
}
private fun baseIntervalMs(): Long {
val latency = ackLatencyMs ?: return RETRY_MIN_MS
return (RETRANSMIT_LATENCY_MULTIPLIER * latency).toLong().coerceIn(RETRY_MIN_MS, RETRY_MAX_MS)
}
// endregion
private inline fun step(block: () -> Unit): ShellStep {
out.clear()
events.clear()
block()
return ShellStep(out.toList(), events.toList())
}
}
@@ -0,0 +1,508 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.remote_shell
import org.meshtastic.core.resources.remote_shell_command_hint
import org.meshtastic.core.resources.remote_shell_last_contact
import org.meshtastic.core.resources.remote_shell_menu_line_mode
import org.meshtastic.core.resources.remote_shell_menu_paste
import org.meshtastic.core.resources.remote_shell_menu_text_larger
import org.meshtastic.core.resources.remote_shell_menu_text_smaller
import org.meshtastic.core.resources.remote_shell_more_options
import org.meshtastic.core.resources.remote_shell_reconnect
import org.meshtastic.core.resources.remote_shell_send
import org.meshtastic.core.resources.remote_shell_status_closed
import org.meshtastic.core.resources.remote_shell_status_closing
import org.meshtastic.core.resources.remote_shell_status_failed
import org.meshtastic.core.resources.remote_shell_status_not_connected
import org.meshtastic.core.resources.remote_shell_status_opening
import org.meshtastic.core.resources.remote_shell_subtitle_round_trip
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Add
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.More
import org.meshtastic.core.ui.icon.Remove
import org.meshtastic.core.ui.icon.Send
import org.meshtastic.core.ui.util.plainText
import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel.InputMode
import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel.SessionState
private val TERMINAL_PADDING = 8.dp
private val BAR_PADDING = 4.dp
private val CHIP_SPACING = 8.dp
/** Let the composition settle before taking focus, or the request is dropped. */
private const val FOCUS_REQUEST_DELAY_MS = 100L
/** Inbound silence, with something of ours in flight, before the screen says the node has gone quiet. */
private const val STALE_AFTER_MS = 4_000L
private const val STALE_POLL_MS = 1_000L
private const val MS_PER_SECOND = 1_000L
private const val RECENT_HISTORY_CHIPS = 5
/**
* Terminal screen for a DMShell session.
*
* In character mode a zero-size field holds keyboard focus, so hardware keys and the soft keyboard both reach the
* session; what was sent shows underlined at the cursor until the node echoes it. In line mode a composer replaces it
* and each command goes out as one frame. The extra-keys rows serve both.
*/
@Suppress("LongMethod")
@Composable
fun RemoteShellScreen(viewModel: RemoteShellViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) {
val screen by viewModel.screen.collectAsStateWithLifecycle()
val sessionState by viewModel.sessionState.collectAsStateWithLifecycle()
val health by viewModel.health.collectAsStateWithLifecycle()
val modifiers by viewModel.modifiers.collectAsStateWithLifecycle()
val inputMode by viewModel.inputMode.collectAsStateWithLifecycle()
val fontSize by viewModel.fontSizeSp.collectAsStateWithLifecycle()
val composer by viewModel.composer.collectAsStateWithLifecycle()
val history by viewModel.history.collectAsStateWithLifecycle()
// The remote PTY wraps to whatever size we declare, so measure the viewport in monospace cells rather than
// shipping a hardcoded 80x24. Opening waits for the measurement so OPEN carries the real size.
var terminalSize by remember { mutableStateOf(IntSize.Zero) }
val (cols, rows) = rememberTerminalGrid(terminalSize, fontSize)
val grid by rememberUpdatedState(cols to rows)
val measuredGrid = remember { snapshotFlow { grid }.filter { (c, r) -> c > 0 && r > 0 }.distinctUntilChanged() }
LaunchedEffect(Unit) { measuredGrid.collect { (c, r) -> viewModel.resize(c, r) } }
// Opens once, on the first measurement: the IME resizes the viewport, and reopening the session every time the
// keyboard moves would churn a session per keystroke burst.
LaunchedEffect(Unit) {
measuredGrid.first()
viewModel.openSession()
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputMode) {
if (inputMode == InputMode.CHARACTER) {
delay(FOCUS_REQUEST_DELAY_MS)
focusRequester.requestFocus()
}
}
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) { viewModel.bell.collect { haptics.performHapticFeedback(HapticFeedbackType.LongPress) } }
val roundTrip = health.roundTripMs
val subtitle =
if (roundTrip != null && sessionState == SessionState.OPEN) {
stringResource(
Res.string.remote_shell_subtitle_round_trip,
stringResource(Res.string.remote_shell),
roundTrip,
)
} else {
stringResource(Res.string.remote_shell)
}
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = viewModel.nodeLongName,
subtitle = subtitle,
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
ShellMenu(
inputMode = inputMode,
onToggleLineMode = {
viewModel.setInputMode(
if (inputMode == InputMode.LINE) InputMode.CHARACTER else InputMode.LINE,
)
},
onPaste = viewModel::paste,
onFontSize = viewModel::adjustFontSize,
)
},
onClickChip = {},
)
},
) { paddingValues ->
Column(modifier = Modifier.fillMaxSize().padding(paddingValues).imePadding()) {
SessionStatusBar(state = sessionState, onReconnect = viewModel::openSession)
Box(modifier = Modifier.weight(1f).fillMaxWidth().onSizeChanged { terminalSize = it }) {
TerminalPane(
screen = screen,
fontSizeSp = fontSize,
showCursor = inputMode == InputMode.CHARACTER,
onTap = { if (inputMode == InputMode.CHARACTER) focusRequester.requestFocus() },
)
if (inputMode == InputMode.CHARACTER) {
KeyboardSink(
focusRequester = focusRequester,
handler =
TerminalKeyHandler(
onChar = viewModel::typeKey,
onEnter = viewModel::typeEnter,
onBackspace = viewModel::typeBackspace,
onKey = viewModel::sendKey,
onChord = viewModel::typeChord,
),
)
}
if (sessionState == SessionState.OPEN) {
LastContactBanner(health, modifier = Modifier.align(Alignment.TopCenter))
}
}
if (inputMode == InputMode.LINE) {
CommandComposer(
composer = composer,
history = history,
masked = screen.secretPrompt,
actions =
ComposerActions(
onChange = viewModel::setComposer,
onInsert = viewModel::insertCommand,
onSubmit = viewModel::submitComposer,
onRecall = viewModel::recallHistory,
),
)
}
// A tapped key cap can take keyboard focus, after which typing would reach nothing; give it straight back.
val refocus = { if (inputMode == InputMode.CHARACTER) focusRequester.requestFocus() }
ExtraKeysBar(
modifiers = modifiers,
onKey = { key ->
when {
inputMode == InputMode.LINE && key == TerminalKey.UP -> viewModel.recallHistory(older = true)
inputMode == InputMode.LINE && key == TerminalKey.DOWN -> viewModel.recallHistory(older = false)
else -> viewModel.sendKey(key)
}
refocus()
},
onChar = { c ->
if (inputMode == InputMode.LINE && modifiers.isEmpty) {
viewModel.setComposer(composer + c)
} else {
viewModel.typeKey(c)
}
refocus()
},
onToggleCtrl = {
viewModel.toggleCtrl()
refocus()
},
onToggleAlt = {
viewModel.toggleAlt()
refocus()
},
)
}
}
}
@Composable
private fun TerminalPane(screen: TerminalScreenState, fontSizeSp: Int, showCursor: Boolean, onTap: () -> Unit) {
val palette = TerminalPalette.from(MaterialTheme.colorScheme)
val listState = rememberLazyListState()
// Follow new output only while the reader is at the bottom; scrolling up to read must not be yanked back.
var follow by remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress to listState.canScrollForward }
.collect { (scrolling, canScrollForward) -> if (scrolling) follow = !canScrollForward }
}
LaunchedEffect(screen) { if (follow && screen.lines.isNotEmpty()) listState.scrollToItem(screen.lines.lastIndex) }
val textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = fontSizeSp.sp, color = palette.foreground)
SelectionContainer {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(TERMINAL_PADDING).clickable(onClick = onTap),
) {
val last = screen.lines.lastIndex
itemsIndexed(screen.lines) { index, line ->
val text =
if (index == last) {
line.withCursor(
cursorColumn = screen.cursorColumn,
predicted = screen.predicted,
unsent = if (screen.typingVisible) screen.unsent else "",
showCursor = showCursor,
palette = palette,
)
} else {
line.toAnnotatedString(palette)
}
Text(text = text, style = textStyle, modifier = Modifier.fillMaxWidth())
}
}
}
}
/** What the line-mode composer can do, grouped so the composable takes state and one handler. */
private class ComposerActions(
val onChange: (String) -> Unit,
val onInsert: (String) -> Unit,
val onSubmit: () -> Unit,
val onRecall: (older: Boolean) -> Unit,
)
@Composable
private fun CommandComposer(composer: String, history: List<String>, masked: Boolean, actions: ComposerActions) {
val chips = remember(history) { (history.take(RECENT_HISTORY_CHIPS) + QUICK_COMMANDS).distinct() }
Column(modifier = Modifier.fillMaxWidth()) {
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(CHIP_SPACING),
contentPadding = PaddingValues(horizontal = TERMINAL_PADDING),
) {
items(chips) { command ->
AssistChip(
onClick = { actions.onInsert(command) },
label = { Text(command, fontFamily = FontFamily.Monospace, maxLines = 1) },
)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = TERMINAL_PADDING),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = composer,
onValueChange = actions.onChange,
modifier = Modifier.weight(1f).onPreviewKeyEvent { event -> composerKey(event, actions) },
placeholder = { Text(stringResource(Res.string.remote_shell_command_hint)) },
textStyle = TextStyle(fontFamily = FontFamily.Monospace),
singleLine = true,
// At a password prompt the answer must not be readable, nor offered to the keyboard's suggestions.
visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = if (masked) KeyboardType.Password else KeyboardType.Text,
imeAction = ImeAction.Send,
),
keyboardActions = KeyboardActions(onSend = { actions.onSubmit() }),
)
IconButton(onClick = actions.onSubmit) {
Icon(MeshtasticIcons.Send, contentDescription = stringResource(Res.string.remote_shell_send))
}
}
}
}
/** Up and Down walk local history, Enter sends; everything else is ordinary text editing. */
private fun composerKey(event: KeyEvent, actions: ComposerActions): Boolean {
val handled = event.type == KeyEventType.KeyDown && event.key in COMPOSER_KEYS
if (handled) {
when (event.key) {
Key.DirectionUp -> actions.onRecall(true)
Key.DirectionDown -> actions.onRecall(false)
else -> actions.onSubmit()
}
}
return handled
}
private val COMPOSER_KEYS = setOf(Key.DirectionUp, Key.DirectionDown, Key.Enter, Key.NumPadEnter)
@Composable
private fun ShellMenu(
inputMode: InputMode,
onToggleLineMode: () -> Unit,
onPaste: (String) -> Unit,
onFontSize: (deltaSp: Int) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Box {
IconButton(onClick = { expanded = true }) {
Icon(MeshtasticIcons.More, contentDescription = stringResource(Res.string.remote_shell_more_options))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.remote_shell_menu_line_mode)) },
trailingIcon = { Checkbox(checked = inputMode == InputMode.LINE, onCheckedChange = null) },
onClick = {
onToggleLineMode()
expanded = false
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.remote_shell_menu_paste)) },
onClick = {
expanded = false
scope.launch { clipboard.getClipEntry()?.plainText()?.let(onPaste) }
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.remote_shell_menu_text_larger)) },
leadingIcon = { Icon(MeshtasticIcons.Add, contentDescription = null) },
onClick = { onFontSize(1) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.remote_shell_menu_text_smaller)) },
leadingIcon = { Icon(MeshtasticIcons.Remove, contentDescription = null) },
onClick = { onFontSize(-1) },
)
}
}
}
/** Reports what the session is doing; without it a refused or stalled session is just a blank screen. */
@Composable
private fun SessionStatusBar(state: SessionState, onReconnect: () -> Unit) {
val label =
when (state) {
SessionState.OPEN -> return
SessionState.IDLE -> Res.string.remote_shell_status_not_connected
SessionState.OPENING -> Res.string.remote_shell_status_opening
SessionState.CLOSING -> Res.string.remote_shell_status_closing
SessionState.CLOSED -> Res.string.remote_shell_status_closed
SessionState.ERROR -> Res.string.remote_shell_status_failed
}
val reconnectable = state == SessionState.CLOSED || state == SessionState.ERROR || state == SessionState.IDLE
StatusRow(text = stringResource(label)) {
if (reconnectable) TextButton(onClick = onReconnect) { Text(stringResource(Res.string.remote_shell_reconnect)) }
}
}
/**
* Mosh's "last contact" line, shown once a frame of ours has gone unacknowledged for [STALE_AFTER_MS], so a slow mesh
* reads as slow rather than broken. It overlays the terminal instead of taking a row: a banner that resized the
* viewport would resize the remote PTY every time it came and went.
*/
@Composable
private fun LastContactBanner(health: LinkHealth, modifier: Modifier = Modifier) {
var now by remember { mutableLongStateOf(nowMillis) }
LaunchedEffect(Unit) {
while (true) {
delay(STALE_POLL_MS)
now = nowMillis
}
}
val waitingSince = health.waitingSinceMs ?: return
if (now - waitingSince < STALE_AFTER_MS) return
val silentSeconds = ((now - health.lastInboundMs) / MS_PER_SECOND).toInt()
StatusRow(text = stringResource(Res.string.remote_shell_last_contact, silentSeconds), modifier = modifier)
}
@Composable
private fun StatusRow(text: String, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) {
Row(
modifier =
modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = TERMINAL_PADDING, vertical = BAR_PADDING),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
trailing()
}
}
/** Viewport size in monospace cells, so the remote PTY can be told how wide to wrap. */
@Composable
private fun rememberTerminalGrid(size: IntSize, fontSizeSp: Int): Pair<Int, Int> {
val textMeasurer = rememberTextMeasurer()
val cell =
remember(textMeasurer, fontSizeSp) {
textMeasurer.measure("0", TextStyle(fontFamily = FontFamily.Monospace, fontSize = fontSizeSp.sp)).size
}
if (cell.width <= 0 || cell.height <= 0) return 0 to 0
return (size.width / cell.width) to (size.height / cell.height)
}
@@ -0,0 +1,499 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
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.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
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.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.remote_shell_error_notice
import org.meshtastic.core.resources.remote_shell_full_screen
import org.meshtastic.core.resources.remote_shell_input_dropped
import org.meshtastic.core.resources.remote_shell_no_reply
import org.meshtastic.core.resources.remote_shell_no_reply_reason
import org.meshtastic.core.resources.remote_shell_session_closed
import org.meshtastic.core.resources.remote_shell_session_closed_reason
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.RemoteShell
private const val MAX_OUTPUT_LINES = 1_000
private const val DEFAULT_COLS = 80
private const val DEFAULT_ROWS = 24
/** Keystroke debounce in character mode, matching the python client's `INPUT_BATCH_WINDOW_SEC`. */
private const val FLUSH_WINDOW_MS = 500L
/** How often [RemoteShellLink.tick] runs: OPEN retries, a shut window, the heartbeat, prediction expiry. */
private const val TICK_MS = 250L
private const val MAX_HISTORY = 50
private const val DEL = "\u007f"
internal const val FONT_SIZE_DEFAULT_SP = 13
internal const val FONT_SIZE_MIN_SP = 9
internal const val FONT_SIZE_MAX_SP = 22
/** Commands worth one tap on a meshtasticd host; inserted into the composer, never sent unseen. */
internal val QUICK_COMMANDS =
listOf(
"uptime",
"df -h",
"free -h",
"ip -br addr",
"systemctl status meshtasticd --no-pager",
"journalctl -u meshtasticd -n 30 --no-pager",
)
/**
* Terminal session against the firmware DMShell module.
*
* The protocol lives in [RemoteShellLink], the screen model in [TerminalOutput] and predictive echo in [LocalEcho]; all
* three are mutated only under [linkMutex], so a frame, a tick and a keystroke never interleave. This class owns the
* clock, the radio and the UI state.
*
* Two input modes. Character mode streams keystrokes (debounced into one frame per burst) so tab completion, line
* editing and prompts behave as on any terminal, and draws what was sent with [LocalEcho] until the node echoes it.
* Line mode composes the whole command locally and sends it in one frame, the cheapest way to spend airtime; it keeps a
* local history so recalling a command costs no round trip.
*/
@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,
private val applicationScope: ApplicationCoroutineScope,
) : ViewModel() {
enum class SessionState {
IDLE,
OPENING,
OPEN,
CLOSING,
CLOSED,
ERROR,
}
enum class InputMode {
CHARACTER,
LINE,
}
private val _sessionState = MutableStateFlow(SessionState.IDLE)
val sessionState: StateFlow<SessionState> = _sessionState.asStateFlow()
private val screenState = MutableStateFlow(TerminalScreenState())
internal val screen: StateFlow<TerminalScreenState> = screenState.asStateFlow()
private val _health = MutableStateFlow(LinkHealth())
val health: StateFlow<LinkHealth> = _health.asStateFlow()
private val _modifiers = MutableStateFlow(Modifiers())
val modifiers: StateFlow<Modifiers> = _modifiers.asStateFlow()
private val _inputMode = MutableStateFlow(InputMode.CHARACTER)
val inputMode: StateFlow<InputMode> = _inputMode.asStateFlow()
private val _composer = MutableStateFlow("")
val composer: StateFlow<String> = _composer.asStateFlow()
private val _history = MutableStateFlow<List<String>>(emptyList())
val history: StateFlow<List<String>> = _history.asStateFlow()
private var historyCursor = -1
private val _fontSizeSp = MutableStateFlow(FONT_SIZE_DEFAULT_SP)
val fontSizeSp: StateFlow<Int> = _fontSizeSp.asStateFlow()
private val _bell = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val bell: SharedFlow<Unit> = _bell.asSharedFlow()
val nodeLongName: String
get() = nodeRepository.nodeDBbyNum.value[destNum]?.user?.long_name ?: destNum.toString()
private val linkMutex = Mutex()
private var link: RemoteShellLink? = null
private val output = TerminalOutput(MAX_OUTPUT_LINES)
private val echo = LocalEcho()
private var tickJob: Job? = null
/** Character mode: typed but not yet handed to the link, still editable locally. */
private val inputBuffer = StringBuilder()
private var flushJob: Job? = null
private var cols = DEFAULT_COLS
private var rows = DEFAULT_ROWS
init {
safeLaunch(context = dispatchers.io, tag = "remoteShellFrameCollector") {
remoteShellHandler.lastFrame.collect { (from, frame) ->
if (from == destNum) withLink { it.receive(frame, nowMillis) }
}
}
}
// region --- Session control ---
fun openSession() {
if (_sessionState.value !in OPENABLE_STATES) return
_sessionState.value = SessionState.OPENING
safeLaunch(context = dispatchers.io, tag = "remoteShellOpen") {
val fresh = RemoteShellLink(sessionId = commandSender.generatePacketId())
linkMutex.withLock { link = fresh }
withLink { it.open(cols, rows, nowMillis) }
startTicking()
}
}
fun closeSession() {
if (_sessionState.value != SessionState.OPEN) return
_sessionState.value = SessionState.CLOSING
safeLaunch(context = dispatchers.io, tag = "remoteShellClose") { withLink { it.close() } }
}
fun resize(cols: Int, rows: Int) {
this.cols = cols
this.rows = rows
safeLaunch(context = dispatchers.io, tag = "remoteShellResize") {
withLink { it.resize(cols, rows, nowMillis) }
}
}
private fun startTicking() {
tickJob?.cancel()
tickJob =
safeLaunch(context = dispatchers.io, tag = "remoteShellTick") {
while (true) {
delay(TICK_MS)
val closed = withLink { link ->
val now = nowMillis
echo.tick(now, link.health.roundTripMs)
link.tick(now)
}
if (closed) break
}
}
}
// endregion
// region --- Character-mode input ---
/** A typed character. With a sticky modifier armed it is sent at once as the modified byte(s). */
fun typeKey(char: Char) {
val mods = _modifiers.value
if (!mods.isEmpty) {
_modifiers.value = mods.consumed()
sendNow(char.withModifiers(mods))
return
}
inputBuffer.append(char)
publishPending()
when {
char == '\t' -> flushBuffer()
inputBuffer.length >= MAX_INPUT_CHUNK_BYTES -> flushBuffer()
else -> scheduleFlush()
}
}
fun typeEnter() {
inputBuffer.append('\r')
flushBuffer()
}
/** Edits the unsent buffer while there is one; past it, the remote line, by sending DEL. */
fun typeBackspace() {
if (inputBuffer.isEmpty()) {
sendNow(DEL)
return
}
inputBuffer.deleteAt(inputBuffer.lastIndex)
publishPending()
if (inputBuffer.isEmpty()) {
flushJob?.cancel()
flushJob = null
} else {
scheduleFlush()
}
}
/** An extra-keys or hardware key. Pending typing goes first so the bytes reach the PTY in the order pressed. */
fun sendKey(key: TerminalKey) {
val mods = _modifiers.value
_modifiers.value = mods.consumed()
val sequence = key.sequence(screenState.value.applicationCursorKeys)
sendNow(if (mods.alt != ModifierState.OFF) "\u001b$sequence" else sequence)
}
/** A hardware Ctrl/Alt chord. Any armed sticky modifier applies on top, as it would to a typed key. */
fun typeChord(char: Char, ctrl: Boolean, alt: Boolean) {
val sticky = _modifiers.value
_modifiers.value = sticky.consumed()
val chord =
Modifiers(
ctrl = if (ctrl || sticky.ctrl != ModifierState.OFF) ModifierState.ONCE else ModifierState.OFF,
alt = if (alt || sticky.alt != ModifierState.OFF) ModifierState.ONCE else ModifierState.OFF,
)
sendNow(char.withModifiers(chord))
}
fun toggleCtrl() = _modifiers.update { it.copy(ctrl = it.ctrl.next()) }
fun toggleAlt() = _modifiers.update { it.copy(alt = it.alt.next()) }
/** Clipboard text: typed in character mode, appended to the composer in line mode. */
fun paste(text: String) {
if (text.isEmpty()) return
if (_inputMode.value == InputMode.LINE) {
_composer.update { it + text }
} else {
sendNow(text.replace("\r\n", "\r").replace('\n', '\r'))
}
}
private fun scheduleFlush() {
flushJob?.cancel()
flushJob = viewModelScope.launch {
delay(FLUSH_WINDOW_MS)
flushBuffer()
}
}
private fun flushBuffer() {
flushJob?.cancel()
flushJob = null
val text = inputBuffer.toString()
inputBuffer.clear()
publishPending()
if (text.isNotEmpty()) send(text)
}
private fun sendNow(text: String) {
val pending = inputBuffer.toString()
inputBuffer.clear()
flushJob?.cancel()
flushJob = null
publishPending()
send(pending + text)
}
private fun send(text: String) {
if (_sessionState.value != SessionState.OPEN) return
safeLaunch(context = dispatchers.io, tag = "remoteShellInput") {
withLink { link ->
val now = nowMillis
echo.onSent(text, now)
link.input(text.encodeUtf8(), now)
}
}
}
// endregion
// region --- Line mode ---
fun setInputMode(mode: InputMode) {
if (mode == InputMode.LINE) flushBuffer()
_inputMode.value = mode
}
fun setComposer(text: String) {
_composer.value = text
historyCursor = -1
}
fun insertCommand(command: String) = setComposer(command)
/** Sends the composed line in one frame; an empty line still sends Enter, which a prompt may be waiting for. */
fun submitComposer() {
val line = _composer.value
_composer.value = ""
historyCursor = -1
// An answer to a password prompt is not a command, and history would keep it on screen as a chip.
if (line.isNotBlank() && !screenState.value.secretPrompt) {
_history.update { (listOf(line) + it.filterNot { old -> old == line }).take(MAX_HISTORY) }
}
sendNow(line + "\r")
}
/** Steps the composer through local history: [older] true walks back, false walks forward to an empty line. */
fun recallHistory(older: Boolean) {
val entries = _history.value
if (entries.isEmpty()) return
historyCursor = (if (older) historyCursor + 1 else historyCursor - 1).coerceIn(-1, entries.lastIndex)
_composer.value = if (historyCursor < 0) "" else entries[historyCursor]
}
// endregion
fun adjustFontSize(deltaSp: Int) = _fontSizeSp.update {
(it + deltaSp).coerceIn(FONT_SIZE_MIN_SP, FONT_SIZE_MAX_SP)
}
// region --- Link plumbing ---
/**
* Runs [block] against the live link and applies what it produced, all under [linkMutex], then sends its frames.
* Returns true once the link is closed or gone.
*/
private suspend fun withLink(block: (RemoteShellLink) -> ShellStep): Boolean {
val (step, closed) =
linkMutex.withLock {
val current = link ?: return true
val step = block(current)
step.events.forEach { applyEvent(it) }
_health.value = current.health
publishScreen()
step to current.isClosed
}
step.send.forEach(::transmit)
return closed
}
/** Caller holds [linkMutex]. */
private suspend fun applyEvent(event: ShellEvent) {
when (event) {
ShellEvent.Opened -> {
_sessionState.value = SessionState.OPEN
Logger.i { "RemoteShell opened with $destNum" }
}
is ShellEvent.Output -> {
val result = output.append(event.bytes)
echo.onOutput(result.echo, nowMillis)
if (result.bell) _bell.tryEmit(Unit)
if (result.enteredFullScreen) output.notice(getString(Res.string.remote_shell_full_screen))
}
is ShellEvent.RemoteError ->
output.notice(getString(Res.string.remote_shell_error_notice, event.message.ifEmpty { "?" }))
is ShellEvent.InputDropped -> output.notice(getString(Res.string.remote_shell_input_dropped, event.bytes))
is ShellEvent.Closed -> {
val wasOpening = _sessionState.value == SessionState.OPENING
output.notice(closedNotice(event.reason, wasOpening))
_sessionState.value = if (wasOpening) SessionState.ERROR else SessionState.CLOSED
}
}
}
// A node that has not authorized us drops OPEN without replying, and so does one out of range.
private suspend fun closedNotice(reason: String, wasOpening: Boolean): String = when {
wasOpening && reason.isEmpty() -> getString(Res.string.remote_shell_no_reply)
wasOpening -> getString(Res.string.remote_shell_no_reply_reason, reason)
reason.isEmpty() -> getString(Res.string.remote_shell_session_closed)
else -> getString(Res.string.remote_shell_session_closed_reason, reason)
}
/** Caller holds [linkMutex]. */
private fun publishScreen() {
val lines = output.lines()
val secret = looksLikeSecretPrompt(lines.last().text.take(output.cursorColumn))
screenState.update {
it.copy(
lines = lines,
cursorColumn = output.cursorColumn,
applicationCursorKeys = output.applicationCursorKeys,
predicted = if (secret) "" else echo.pending,
typingVisible = echo.showsTyping && !secret,
secretPrompt = secret,
)
}
}
private fun publishPending() = screenState.update { it.copy(unsent = inputBuffer.toString()) }
private fun transmit(frame: RemoteShell) {
val myNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0
val packet =
DataPacket(
to = NodeAddress.numToDefaultId(destNum),
from = NodeAddress.numToDefaultId(myNum),
bytes = RemoteShell.ADAPTER.encode(frame).toByteString(),
dataType = PortNum.REMOTE_SHELL_APP.value,
channel = NodeAddress.PKC_CHANNEL_INDEX,
wantAck = false,
)
// Not viewModelScope: the CLOSE sent from onCleared would otherwise never leave.
applicationScope.launch(dispatchers.io) {
safeCatching { commandSender.sendData(packet) }
.onFailure { Logger.w(it) { "RemoteShell send failed op=${frame.op} seq=${frame.seq}" } }
}
}
// endregion
override fun onCleared() {
super.onCleared()
tickJob?.cancel()
// viewModelScope is already cancelled, and a step already running on it is not interrupted by that, so the
// close takes linkMutex on applicationScope like every other caller of the link.
applicationScope.launch(dispatchers.io) {
val frames = linkMutex.withLock { link?.takeIf { !it.isClosed }?.close()?.send.orEmpty() }
frames.forEach(::transmit)
}
Logger.d { "RemoteShellViewModel cleared for destNum=$destNum" }
}
private companion object {
val OPENABLE_STATES = setOf(SessionState.IDLE, SessionState.CLOSED, SessionState.ERROR)
}
}
/**
* Everything the terminal pane draws. [predicted] was sent and awaits echo; [unsent] is still in the debounce. Neither
* is drawn unless [typingVisible]: the line has confirmed that the remote echoes, and is not a [secretPrompt].
*/
internal data class TerminalScreenState(
val lines: List<TerminalLine> = listOf(TerminalLine.Empty),
val cursorColumn: Int = 0,
val applicationCursorKeys: Boolean = false,
val predicted: String = "",
val unsent: String = "",
val typingVisible: Boolean = false,
val secretPrompt: Boolean = false,
)
@@ -0,0 +1,127 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
/** Keys a soft keyboard lacks and a shell needs. */
enum class TerminalKey {
ESCAPE,
TAB,
UP,
DOWN,
LEFT,
RIGHT,
HOME,
END,
PAGE_UP,
PAGE_DOWN,
DELETE,
}
/** Sticky modifiers from the extra-keys row: each applies to the next key, then releases unless locked. */
data class Modifiers(val ctrl: ModifierState = ModifierState.OFF, val alt: ModifierState = ModifierState.OFF) {
val isEmpty: Boolean
get() = ctrl == ModifierState.OFF && alt == ModifierState.OFF
/** What remains after a key has used them: one-shot modifiers release, locked ones stay. */
fun consumed(): Modifiers = Modifiers(ctrl.afterUse(), alt.afterUse())
}
enum class ModifierState {
OFF,
ONCE,
LOCKED,
;
/** A tap cycles off -> once -> locked -> off, as Termux and Blink do. */
fun next(): ModifierState = when (this) {
OFF -> ONCE
ONCE -> LOCKED
LOCKED -> OFF
}
fun afterUse(): ModifierState = if (this == ONCE) OFF else this
}
private const val ESC = "\u001b"
private const val CTRL_MASK = 0x1f
private const val DEL = '\u007f'
/** What one edit to the keyboard sink typed: [deleted] backspaces, then [inserted] text. */
internal data class SinkEdit(val deleted: Int, val inserted: String)
/** One changed range of the keyboard sink: [replacedLength] characters of the old text gave way to [inserted]. */
internal class SinkChange(val replacedLength: Int, val inserted: String)
/**
* The input a set of sink changes amounts to. A change that only removes text is a backspace; one that inserts text
* types exactly what it inserted, so an IME replacing the sentinel, or a paste over it, sends no stray backspace and
* keeps a leading space.
*/
internal fun sinkEdit(changes: List<SinkChange>): SinkEdit {
var deleted = 0
val inserted = StringBuilder()
for (change in changes) {
if (change.inserted.isEmpty()) deleted += change.replacedLength else inserted.append(change.inserted)
}
return SinkEdit(deleted, inserted.toString())
}
/** The bytes a VT100-family terminal sends for a key, honouring DECCKM for the cursor keys. */
internal fun TerminalKey.sequence(applicationCursorKeys: Boolean): String {
val cursorPrefix = if (applicationCursorKeys) "${ESC}O" else "$ESC["
return when (this) {
TerminalKey.ESCAPE -> ESC
TerminalKey.TAB -> "\t"
TerminalKey.UP -> "${cursorPrefix}A"
TerminalKey.DOWN -> "${cursorPrefix}B"
TerminalKey.RIGHT -> "${cursorPrefix}C"
TerminalKey.LEFT -> "${cursorPrefix}D"
TerminalKey.HOME -> "${cursorPrefix}H"
TerminalKey.END -> "${cursorPrefix}F"
TerminalKey.PAGE_UP -> "$ESC[5~"
TerminalKey.PAGE_DOWN -> "$ESC[6~"
TerminalKey.DELETE -> "$ESC[3~"
}
}
/**
* A typed character with [modifiers] applied: Ctrl maps letters and `@[\]^_?` to their C0 codes (`?` to DEL), and Alt
* prefixes ESC, as xterm's default `metaSendsEscape` does.
*/
internal fun Char.withModifiers(modifiers: Modifiers): String {
var c = this
if (modifiers.ctrl != ModifierState.OFF) {
c =
when (val upper = c.uppercaseChar()) {
in 'A'..'Z',
'@',
'[',
'\\',
']',
'^',
'_',
-> (upper.code and CTRL_MASK).toChar()
'?' -> DEL
' ' -> '\u0000'
else -> c
}
}
return if (modifiers.alt != ModifierState.OFF) "$ESC$c" else c.toString()
}
@@ -0,0 +1,334 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
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.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isAltPressed
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.remote_shell_key_alt
import org.meshtastic.core.resources.remote_shell_key_ctrl
import org.meshtastic.core.resources.remote_shell_key_down
import org.meshtastic.core.resources.remote_shell_key_end
import org.meshtastic.core.resources.remote_shell_key_escape
import org.meshtastic.core.resources.remote_shell_key_home
import org.meshtastic.core.resources.remote_shell_key_left
import org.meshtastic.core.resources.remote_shell_key_page_down
import org.meshtastic.core.resources.remote_shell_key_page_up
import org.meshtastic.core.resources.remote_shell_key_right
import org.meshtastic.core.resources.remote_shell_key_tab
import org.meshtastic.core.resources.remote_shell_key_up
import org.meshtastic.core.resources.remote_shell_modifier_locked
import org.meshtastic.core.resources.remote_shell_modifier_once
private val KEY_HEIGHT = 48.dp
private val KEY_SPACING = 2.dp
private val KEY_LABEL_SIZE = 13.sp
/** What the keyboard sink always holds between edits. */
private const val SINK_SENTINEL = " "
private sealed interface ExtraKey {
data class Special(val key: TerminalKey, val label: String, val description: StringResource) : ExtraKey
data class Typed(val char: Char) : ExtraKey
data object Ctrl : ExtraKey
data object Alt : ExtraKey
}
/** Termux's default extra-keys layout: the two rows its users already have in their thumbs. */
private val EXTRA_KEY_ROWS: List<List<ExtraKey>> =
listOf(
listOf(
ExtraKey.Special(TerminalKey.ESCAPE, "ESC", Res.string.remote_shell_key_escape),
ExtraKey.Typed('/'),
ExtraKey.Typed('-'),
ExtraKey.Special(TerminalKey.HOME, "HOME", Res.string.remote_shell_key_home),
ExtraKey.Special(TerminalKey.UP, "↑", Res.string.remote_shell_key_up),
ExtraKey.Special(TerminalKey.END, "END", Res.string.remote_shell_key_end),
ExtraKey.Special(TerminalKey.PAGE_UP, "PGUP", Res.string.remote_shell_key_page_up),
),
listOf(
ExtraKey.Special(TerminalKey.TAB, "TAB", Res.string.remote_shell_key_tab),
ExtraKey.Ctrl,
ExtraKey.Alt,
ExtraKey.Special(TerminalKey.LEFT, "←", Res.string.remote_shell_key_left),
ExtraKey.Special(TerminalKey.DOWN, "↓", Res.string.remote_shell_key_down),
ExtraKey.Special(TerminalKey.RIGHT, "→", Res.string.remote_shell_key_right),
ExtraKey.Special(TerminalKey.PAGE_DOWN, "PGDN", Res.string.remote_shell_key_page_down),
),
)
/**
* The extra-keys rows. CTRL and ALT are sticky: one tap applies to the next key, a second tap locks them, a third
* releases.
*/
@Composable
internal fun ExtraKeysBar(
modifiers: Modifiers,
onKey: (TerminalKey) -> Unit,
onChar: (Char) -> Unit,
onToggleCtrl: () -> Unit,
onToggleAlt: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth().padding(horizontal = KEY_SPACING)) {
EXTRA_KEY_ROWS.forEach { row ->
Row(modifier = Modifier.fillMaxWidth()) {
row.forEach { key ->
when (key) {
is ExtraKey.Special ->
KeyCap(label = key.label, description = stringResource(key.description)) { onKey(key.key) }
is ExtraKey.Typed ->
KeyCap(label = key.char.toString(), description = null) { onChar(key.char) }
ExtraKey.Ctrl ->
KeyCap(
label = "CTRL",
description = stringResource(Res.string.remote_shell_key_ctrl),
state = modifiers.ctrl,
onClick = onToggleCtrl,
)
ExtraKey.Alt ->
KeyCap(
label = "ALT",
description = stringResource(Res.string.remote_shell_key_alt),
state = modifiers.alt,
onClick = onToggleAlt,
)
}
}
}
}
}
}
@Composable
private fun RowScope.KeyCap(label: String, description: String?, state: ModifierState? = null, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val (container, content) =
when (state) {
ModifierState.ONCE -> colors.primaryContainer to colors.onPrimaryContainer
ModifierState.LOCKED -> colors.primary to colors.onPrimary
else -> Color.Transparent to colors.primary
}
val stateText =
when (state) {
ModifierState.ONCE -> stringResource(Res.string.remote_shell_modifier_once)
ModifierState.LOCKED -> stringResource(Res.string.remote_shell_modifier_locked)
else -> null
}
Box(
modifier =
Modifier.weight(1f)
.height(KEY_HEIGHT)
.padding(KEY_SPACING)
.background(container, MaterialTheme.shapes.small)
.clickable(role = Role.Button, onClick = onClick)
.semantics {
description?.let { contentDescription = it }
stateText?.let { stateDescription = it }
},
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = content,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = KEY_LABEL_SIZE,
maxLines = 1,
)
}
}
/** Hardware keys a terminal must send itself, rather than let the text field interpret. */
private val NAVIGATION_KEYS =
mapOf(
Key.DirectionUp to TerminalKey.UP,
Key.DirectionDown to TerminalKey.DOWN,
Key.DirectionLeft to TerminalKey.LEFT,
Key.DirectionRight to TerminalKey.RIGHT,
Key.MoveHome to TerminalKey.HOME,
Key.MoveEnd to TerminalKey.END,
Key.PageUp to TerminalKey.PAGE_UP,
Key.PageDown to TerminalKey.PAGE_DOWN,
Key.Escape to TerminalKey.ESCAPE,
Key.Delete to TerminalKey.DELETE,
)
private val LETTER_KEYS =
listOf(
Key.A,
Key.B,
Key.C,
Key.D,
Key.E,
Key.F,
Key.G,
Key.H,
Key.I,
Key.J,
Key.K,
Key.L,
Key.M,
Key.N,
Key.O,
Key.P,
Key.Q,
Key.R,
Key.S,
Key.T,
Key.U,
Key.V,
Key.W,
Key.X,
Key.Y,
Key.Z,
)
.mapIndexed { i, key -> key to ('a' + i) }
.toMap()
/** Callbacks from the keyboard sink, grouped so the composable stays readable. */
internal class TerminalKeyHandler(
val onChar: (Char) -> Unit,
val onEnter: () -> Unit,
val onBackspace: () -> Unit,
val onKey: (TerminalKey) -> Unit,
val onChord: (Char, ctrl: Boolean, alt: Boolean) -> Unit,
)
/**
* Zero-size field that holds keyboard focus so both hardware keys and the soft keyboard reach the session.
*
* It never holds what was typed. Each edit - a typed character, an IME commit, a soft-keyboard backspace, a paste - is
* read as terminal input and reverted in the same transformation, so the field stays at [SINK_SENTINEL] with the caret
* at its end; the sentinel is there so a soft backspace has something to delete. Keys the terminal sends itself are
* taken in the preview pass, before the field could move its caret or edit with them.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun KeyboardSink(focusRequester: FocusRequester, handler: TerminalKeyHandler) {
val state = rememberTextFieldState(SINK_SENTINEL)
val currentHandler by rememberUpdatedState(handler)
val transformation = remember {
InputTransformation {
val text = asCharSequence()
val edit =
sinkEdit(
(0 until changes.changeCount).map { i ->
val range = changes.getRange(i)
SinkChange(
changes.getOriginalRange(i).length,
text.subSequence(range.min, range.max).toString(),
)
},
)
revertAllChanges()
repeat(edit.deleted) { currentHandler.onBackspace() }
edit.inserted.forEach { c ->
if (c == '\n' || c == '\r') currentHandler.onEnter() else currentHandler.onChar(c)
}
}
}
BasicTextField(
state = state,
inputTransformation = transformation,
modifier =
Modifier.size(1.dp).focusRequester(focusRequester).onPreviewKeyEvent { handleKey(it, currentHandler) },
textStyle = TextStyle(color = Color.Transparent, fontSize = 1.sp),
cursorBrush = SolidColor(Color.Transparent),
// No suggestions or composing: an IME rewriting a word in place would replay it as keystrokes.
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
),
)
}
/** Handles the key if it is one the terminal sends itself; returns whether it did. */
private fun handleKey(event: KeyEvent, handler: TerminalKeyHandler): Boolean {
val navigation = NAVIGATION_KEYS[event.key]
val chordLetter = LETTER_KEYS[event.key]?.takeIf { event.isCtrlPressed || event.isAltPressed }
val isEnter = event.key == Key.Enter || event.key == Key.NumPadEnter
val handled =
event.type == KeyEventType.KeyDown &&
(navigation != null || chordLetter != null || isEnter || event.key in SINK_EDIT_KEYS)
if (handled) {
when {
navigation != null -> handler.onKey(navigation)
chordLetter != null -> handler.onChord(chordLetter, event.isCtrlPressed, event.isAltPressed)
isEnter -> handler.onEnter()
event.key == Key.Tab -> handler.onKey(TerminalKey.TAB)
else -> handler.onBackspace()
}
}
return handled
}
private val SINK_EDIT_KEYS = setOf(Key.Tab, Key.Backspace)
@@ -0,0 +1,534 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import okio.ByteString
/** Colour of a cell: [DEFAULT_COLOR], an xterm palette index 0-255, or [TRUECOLOR_FLAG] or-ed with 0xRRGGBB. */
internal const val DEFAULT_COLOR = -1
internal const val TRUECOLOR_FLAG = 0x1000000
internal data class CellStyle(
val fg: Int = DEFAULT_COLOR,
val bg: Int = DEFAULT_COLOR,
val bold: Boolean = false,
val dim: Boolean = false,
val italic: Boolean = false,
val underline: Boolean = false,
val inverse: Boolean = false,
) {
companion object {
val Plain = CellStyle()
}
}
/** A run of [style] over `[start, end)` of a [TerminalLine]'s text. */
internal data class StyleRun(val start: Int, val end: Int, val style: CellStyle)
internal data class TerminalLine(val text: String, val runs: List<StyleRun>, val isNotice: Boolean = false) {
companion object {
val Empty = TerminalLine("", emptyList())
}
}
/** What one chunk of output did, for the parts of the UI that react to it rather than render it. */
internal sealed interface EchoEvent {
/** A printable character written at the cursor. */
data class Printed(val char: Char) : EchoEvent
/** Anything that moved or rewrote the line other than printing: CR, LF, BS, an erase, a cursor move. */
data object Edited : EchoEvent
}
internal class AppendResult(val echo: List<EchoEvent>, val bell: Boolean, val enteredFullScreen: Boolean)
private const val ESC = '\u001b'
private const val BEL = '\u0007'
private const val TAB_STOP = 8
/** Longest parameter string kept for one CSI sequence; a hostile or broken stream cannot grow it past this. */
private const val MAX_CSI_LENGTH = 64
/**
* Widest a line may grow. Cursor and insert parameters come from the remote, so `ESC[999999999C` must not be able to
* pad a line with a billion cells; printing past it wraps to a new line.
*/
internal const val MAX_LINE_WIDTH = 1024
private const val SGR_TRUECOLOR = 2
private const val SGR_PALETTE = 5
private const val SGR_RGB_ARGS = 3
private const val MAX_PALETTE = 255
private const val BYTE_SHIFT = 8
private const val ALT_SCREEN_1049 = 1049
private const val ALT_SCREEN_47 = 47
private const val ALT_SCREEN_1047 = 1047
private const val DECCKM = 1
private const val ERASE_TO_END = 0
private const val ERASE_TO_START = 1
private const val ERASE_ALL = 2
private const val UTF8_CONTINUATION_MASK = 0xC0
private const val UTF8_CONTINUATION = 0x80
private const val UTF8_LEAD_2 = 0xE0
private const val UTF8_LEAD_2_BITS = 0xC0
private const val UTF8_LEAD_3 = 0xF0
private const val UTF8_LEAD_3_BITS = 0xE0
private const val UTF8_LEAD_4 = 0xF8
private const val UTF8_LEAD_4_BITS = 0xF0
private const val BYTE_MASK = 0xFF
private const val UTF8_LEN_2 = 2
private const val UTF8_LEN_3 = 3
private const val UTF8_LEN_4 = 4
/**
* PTY output as styled lines - a line-oriented subset of a VT100/xterm screen.
*
* It keeps what a shell over a slow link actually produces: SGR colour and attributes, `\r` overwrite, backspace, tabs,
* erase-in-line, horizontal cursor moves, insert/delete characters and `clear`. Vertical cursor addressing and the
* alternate screen are not modelled - a full-screen program redraws a whole screen per keystroke, which the mesh cannot
* carry - so their sequences are consumed and [AppendResult.enteredFullScreen] lets the UI say so.
*
* An OUTPUT frame is whatever one PTY read returned, so frame boundaries fall mid-line, mid-sequence and mid-character:
* parser state and a split UTF-8 tail both carry across [append] calls. The last line is always the one being written,
* which is where the screen draws the cursor.
*/
@Suppress("TooManyFunctions")
internal class TerminalOutput(private val maxLines: Int) {
private class Row(var isNotice: Boolean = false) {
val chars = StringBuilder()
val styles = ArrayList<CellStyle>()
var snapshot: TerminalLine? = null
}
private enum class State {
GROUND,
ESCAPE,
ESCAPE_INTERMEDIATE,
CSI,
OSC,
OSC_ESCAPE,
STRING,
STRING_ESCAPE,
}
private val rows = ArrayDeque<Row>().apply { addLast(Row()) }
private var carry = ByteArray(0)
private var state = State.GROUND
private val csi = StringBuilder()
private var style = CellStyle.Plain
/** Column of the cursor on the last line. */
var cursorColumn = 0
private set
/** DECCKM: arrow keys must be sent as `ESC O x` rather than `ESC [ x` while a program asks for it. */
var applicationCursorKeys = false
private set
private val echo = ArrayList<EchoEvent>()
private var bell = false
private var enteredFullScreen = false
fun lines(): List<TerminalLine> = rows.map { row ->
row.snapshot ?: snapshot(row).also { row.snapshot = it }
}
fun append(bytes: ByteString): AppendResult {
echo.clear()
bell = false
enteredFullScreen = false
val all = carry + bytes.toByteArray()
val complete = completeUtf8Length(all)
carry = all.copyOfRange(complete, all.size)
all.decodeToString(0, complete).forEach(::feed)
return AppendResult(echo.toList(), bell, enteredFullScreen)
}
/** A line of our own, never merged into what the PTY is writing. */
fun notice(text: String) {
val current = rows.last()
val target = if (current.chars.isEmpty()) current else Row().also { rows.addLast(it) }
target.isNotice = true
target.chars.append(text)
repeat(text.length) { target.styles.add(CellStyle.Plain) }
target.snapshot = null
newLine()
}
// region --- Parser ---
@Suppress("CyclomaticComplexMethod")
private fun feed(c: Char) {
when (state) {
State.GROUND -> ground(c)
State.ESCAPE -> escape(c)
State.ESCAPE_INTERMEDIATE -> state = State.GROUND
State.CSI ->
if (c in '@'..'~') {
dispatchCsi(c)
state = State.GROUND
} else if (csi.length < MAX_CSI_LENGTH) {
csi.append(c)
}
State.OSC ->
when (c) {
BEL -> state = State.GROUND
ESC -> state = State.OSC_ESCAPE
else -> Unit
}
State.OSC_ESCAPE -> state = if (c == '\\') State.GROUND else State.OSC
State.STRING -> if (c == ESC) state = State.STRING_ESCAPE
State.STRING_ESCAPE -> state = if (c == '\\') State.GROUND else State.STRING
}
}
private fun ground(c: Char) {
when (c) {
ESC -> state = State.ESCAPE
'\n' -> {
newLine()
echo += EchoEvent.Edited
}
'\r' -> {
cursorColumn = 0
echo += EchoEvent.Edited
}
'\b' -> {
if (cursorColumn > 0) cursorColumn--
echo += EchoEvent.Edited
}
'\t' -> {
val next = (cursorColumn / TAB_STOP + 1) * TAB_STOP
while (cursorColumn < next) put(' ')
echo += EchoEvent.Edited
}
BEL -> bell = true
else ->
if (!c.isISOControl()) {
put(c)
echo += EchoEvent.Printed(c)
}
}
}
private fun escape(c: Char) {
state =
when (c) {
'[' -> {
csi.clear()
State.CSI
}
']' -> State.OSC
// DCS, SOS, PM, APC: consumed up to the string terminator.
'P',
'X',
'^',
'_',
-> State.STRING
// Charset designations and other two-byte sequences carry one more byte.
'(',
')',
'*',
'+',
'#',
'%',
-> State.ESCAPE_INTERMEDIATE
'c' -> {
clearScreen()
State.GROUND
}
else -> State.GROUND
}
}
@Suppress("CyclomaticComplexMethod")
private fun dispatchCsi(final: Char) {
val raw = csi.toString()
val private = raw.startsWith('?')
val params = raw.trimStart('?', '>', '=', '<').split(';').map { it.toIntOrNull() }
fun arg(i: Int, default: Int = 1): Int = params.getOrNull(i)?.takeIf { it > 0 } ?: default
if (private) {
if (final == 'h' || final == 'l') setPrivateModes(params, final == 'h')
return
}
when (final) {
'm' -> sgr(params)
'K' -> eraseInLine(params.firstOrNull() ?: ERASE_TO_END)
'J' -> if ((params.firstOrNull() ?: ERASE_TO_END) >= ERASE_ALL) clearScreen()
'C' -> moveCursor(cursorColumn + arg(0))
'D' -> moveCursor(cursorColumn - arg(0))
'G',
'`',
-> moveCursor(arg(0) - 1)
// Line-oriented: only the column of an absolute position is meaningful.
'H',
'f',
-> moveCursor(arg(1) - 1)
'P' -> deleteChars(arg(0))
'@' -> insertBlanks(arg(0))
'X' -> eraseChars(arg(0))
else -> return
}
echo += EchoEvent.Edited
}
private fun setPrivateModes(params: List<Int?>, on: Boolean) {
params.forEach { mode ->
when (mode) {
DECCKM -> applicationCursorKeys = on
ALT_SCREEN_1049,
ALT_SCREEN_47,
ALT_SCREEN_1047,
-> if (on) enteredFullScreen = true
else -> Unit
}
}
}
// The numbers are ECMA-48's SGR parameters; naming each would only restate the table.
@Suppress("CyclomaticComplexMethod", "MagicNumber")
private fun sgr(params: List<Int?>) {
var i = 0
if (params.isEmpty()) style = CellStyle.Plain
while (i < params.size) {
val p = params[i] ?: 0
style =
when (p) {
0 -> CellStyle.Plain
1 -> style.copy(bold = true)
2 -> style.copy(dim = true)
3 -> style.copy(italic = true)
4 -> style.copy(underline = true)
7 -> style.copy(inverse = true)
22 -> style.copy(bold = false, dim = false)
23 -> style.copy(italic = false)
24 -> style.copy(underline = false)
27 -> style.copy(inverse = false)
in 30..37 -> style.copy(fg = p - 30)
39 -> style.copy(fg = DEFAULT_COLOR)
in 40..47 -> style.copy(bg = p - 40)
49 -> style.copy(bg = DEFAULT_COLOR)
in 90..97 -> style.copy(fg = p - 90 + 8)
in 100..107 -> style.copy(bg = p - 100 + 8)
38,
48,
-> {
val (color, used) = extendedColor(params, i + 1)
i += used
if (color == null) {
style
} else if (p == 38) {
style.copy(fg = color)
} else {
style.copy(bg = color)
}
}
else -> style
}
i++
}
}
/** `5;n` or `2;r;g;b` after a 38/48. Returns the colour and how many parameters it consumed. */
private fun extendedColor(params: List<Int?>, at: Int): Pair<Int?, Int> = when (params.getOrNull(at)) {
SGR_PALETTE -> params.getOrNull(at + 1)?.coerceIn(0, MAX_PALETTE) to 2
SGR_TRUECOLOR -> {
val rgb = (1..SGR_RGB_ARGS).map { (params.getOrNull(at + it) ?: 0).coerceIn(0, BYTE_MASK) }
(TRUECOLOR_FLAG or (rgb[0] shl (2 * BYTE_SHIFT)) or (rgb[1] shl BYTE_SHIFT) or rgb[2]) to
(1 + SGR_RGB_ARGS)
}
else -> null to 0
}
// endregion
// region --- Line editing ---
private fun current(): Row = rows.last()
private fun put(c: Char) {
if (cursorColumn >= MAX_LINE_WIDTH) newLine()
val row = current()
padTo(row, cursorColumn)
if (cursorColumn < row.chars.length) {
row.chars[cursorColumn] = c
row.styles[cursorColumn] = style
} else {
row.chars.append(c)
row.styles.add(style)
}
row.snapshot = null
cursorColumn++
}
private fun padTo(row: Row, column: Int) {
while (row.chars.length < column) {
row.chars.append(' ')
row.styles.add(CellStyle.Plain)
}
}
private fun moveCursor(column: Int) {
cursorColumn = column.coerceIn(0, MAX_LINE_WIDTH - 1)
}
private fun eraseInLine(mode: Int) {
val row = current()
when (mode) {
ERASE_TO_END -> truncate(row, cursorColumn)
ERASE_TO_START -> {
padTo(row, cursorColumn)
for (i in 0 until minOf(cursorColumn + 1, row.chars.length)) {
row.chars[i] = ' '
row.styles[i] = CellStyle.Plain
}
}
else -> truncate(row, 0)
}
row.snapshot = null
}
private fun truncate(row: Row, length: Int) {
if (length < row.chars.length) {
row.chars.setLength(length)
while (row.styles.size > length) row.styles.removeAt(row.styles.lastIndex)
}
}
private fun deleteChars(count: Int) {
val row = current()
val end = minOf(cursorColumn + count, row.chars.length)
if (cursorColumn >= end) return
row.chars.deleteRange(cursorColumn, end)
repeat(end - cursorColumn) { row.styles.removeAt(cursorColumn) }
row.snapshot = null
}
private fun insertBlanks(count: Int) {
val row = current()
val blanks = count.coerceAtMost(MAX_LINE_WIDTH - row.chars.length)
if (cursorColumn >= row.chars.length || blanks <= 0) return
row.chars.insert(cursorColumn, " ".repeat(blanks))
repeat(blanks) { row.styles.add(cursorColumn, CellStyle.Plain) }
row.snapshot = null
}
private fun eraseChars(count: Int) {
val row = current()
for (i in cursorColumn until minOf(cursorColumn + count, row.chars.length)) {
row.chars[i] = ' '
row.styles[i] = CellStyle.Plain
}
row.snapshot = null
}
private fun newLine() {
rows.addLast(Row())
cursorColumn = 0
while (rows.size > maxLines) rows.removeFirst()
}
private fun clearScreen() {
rows.clear()
rows.addLast(Row())
cursorColumn = 0
}
// endregion
private fun snapshot(row: Row): TerminalLine {
if (row.chars.isEmpty()) return if (row.isNotice) TerminalLine("", emptyList(), true) else TerminalLine.Empty
val runs = ArrayList<StyleRun>()
var start = 0
for (i in 1..row.styles.size) {
if (i == row.styles.size || row.styles[i] != row.styles[start]) {
if (row.styles[start] != CellStyle.Plain) runs += StyleRun(start, i, row.styles[start])
start = i
}
}
return TerminalLine(row.chars.toString(), runs, row.isNotice)
}
/** Length of [bytes] up to, not including, a trailing UTF-8 sequence that is still missing bytes. */
private fun completeUtf8Length(bytes: ByteArray): Int {
var start = bytes.size - 1
while (start >= 0 && (bytes[start].toInt() and UTF8_CONTINUATION_MASK) == UTF8_CONTINUATION) start--
if (start < 0) return bytes.size
val lead = bytes[start].toInt() and BYTE_MASK
val needed =
when {
lead and UTF8_LEAD_2 == UTF8_LEAD_2_BITS -> UTF8_LEN_2
lead and UTF8_LEAD_3 == UTF8_LEAD_3_BITS -> UTF8_LEN_3
lead and UTF8_LEAD_4 == UTF8_LEAD_4_BITS -> UTF8_LEN_4
else -> 1
}
return if (bytes.size - start < needed) start else bytes.size
}
}
@@ -0,0 +1,186 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
/** Block drawn at the cursor. */
internal const val CURSOR_GLYPH = "█"
private const val ANSI_BRIGHT_OFFSET = 8
private const val DIM_ALPHA = 0.6f
private const val UNSENT_ALPHA = 0.45f
private const val LUMINANCE_DARK = 0.5f
private const val CUBE_BASE = 16
private const val CUBE_SIDE = 6
private const val GRAY_BASE = 232
private const val CUBE_FIRST_STEP = 55
private const val CUBE_STEP = 40
private const val GRAY_FIRST = 8
private const val GRAY_STEP = 10
private const val RGB_MASK = 0xFFFFFF
private const val OPAQUE = 0xFF000000L
private const val BYTE = 8
/** The 16 ANSI colours for the theme's surface: Tango-derived, with a darker set for light themes. */
@Immutable
internal class TerminalPalette(
val ansi: List<Color>,
val foreground: Color,
val background: Color,
val notice: Color,
) {
/** Colour for a [CellStyle] colour value, or null for the default. */
fun resolve(value: Int): Color? = when {
value == DEFAULT_COLOR -> null
value and TRUECOLOR_FLAG != 0 -> Color(OPAQUE or (value and RGB_MASK).toLong())
value < CUBE_BASE -> ansi[value]
value < GRAY_BASE -> cube(value - CUBE_BASE)
else -> gray(value - GRAY_BASE)
}
private fun cube(index: Int): Color {
fun step(v: Int) = if (v == 0) 0 else CUBE_FIRST_STEP + v * CUBE_STEP
val r = step(index / (CUBE_SIDE * CUBE_SIDE))
val g = step(index / CUBE_SIDE % CUBE_SIDE)
val b = step(index % CUBE_SIDE)
return Color(OPAQUE or (r.toLong() shl (2 * BYTE)) or (g.toLong() shl BYTE) or b.toLong())
}
private fun gray(index: Int): Color {
val v = (GRAY_FIRST + index * GRAY_STEP).toLong()
return Color(OPAQUE or (v shl (2 * BYTE)) or (v shl BYTE) or v)
}
companion object {
private val DARK =
listOf(
0xFF2E3436,
0xFFEF5350,
0xFF8AE234,
0xFFFCE94F,
0xFF729FCF,
0xFFC397D8,
0xFF34E2E2,
0xFFD3D7CF,
0xFF888A85,
0xFFFF7B7B,
0xFFB5F27A,
0xFFFFF59D,
0xFF9EC3F0,
0xFFE0B8F0,
0xFF8AF0F0,
0xFFEEEEEC,
)
.map(::Color)
private val LIGHT =
listOf(
0xFF2E3436,
0xFFB71C1C,
0xFF2E7D32,
0xFF8D6E00,
0xFF1E5AA8,
0xFF7B3F9E,
0xFF00796B,
0xFF5F6368,
0xFF555753,
0xFFC62828,
0xFF1B5E20,
0xFF795548,
0xFF0D47A1,
0xFF6A1B9A,
0xFF00695C,
0xFF202124,
)
.map(::Color)
fun from(scheme: ColorScheme): TerminalPalette = TerminalPalette(
ansi = if (scheme.surface.luminance() < LUMINANCE_DARK) DARK else LIGHT,
foreground = scheme.onSurface,
background = scheme.surface,
notice = scheme.onSurfaceVariant,
)
}
}
internal fun CellStyle.toSpanStyle(palette: TerminalPalette): SpanStyle {
var fg = palette.resolve(fg)
var bg = palette.resolve(bg)
// Bold brightens the eight base colours, as xterm's boldColors does.
if (bold && this.fg in 0 until ANSI_BRIGHT_OFFSET) fg = palette.ansi[this.fg + ANSI_BRIGHT_OFFSET]
if (inverse) {
val swappedFg = bg ?: palette.background
bg = fg ?: palette.foreground
fg = swappedFg
}
if (dim) fg = (fg ?: palette.foreground).copy(alpha = DIM_ALPHA)
return SpanStyle(
color = fg ?: Color.Unspecified,
background = bg ?: Color.Unspecified,
fontWeight = if (bold) FontWeight.Bold else null,
fontStyle = if (italic) FontStyle.Italic else null,
textDecoration = if (underline) TextDecoration.Underline else null,
)
}
/** One output line with its style runs applied. */
internal fun TerminalLine.toAnnotatedString(palette: TerminalPalette): AnnotatedString = buildAnnotatedString {
if (isNotice) {
withStyle(SpanStyle(color = palette.notice, fontStyle = FontStyle.Italic)) { append(text) }
return@buildAnnotatedString
}
append(text)
runs.forEach { addStyle(it.style.toSpanStyle(palette), it.start, it.end) }
}
/**
* The line being written, with what is still in flight drawn at the cursor: [predicted] (sent, awaiting echo)
* underlined in the text colour, [unsent] (still in the keystroke debounce) underlined and faded. Text already right of
* the cursor follows, as the remote line would show it once the echo lands.
*/
internal fun TerminalLine.withCursor(
cursorColumn: Int,
predicted: String,
unsent: String,
showCursor: Boolean,
palette: TerminalPalette,
): AnnotatedString = buildAnnotatedString {
val split = cursorColumn.coerceIn(0, text.length)
val full = toAnnotatedString(palette)
append(full.subSequence(0, split))
if (cursorColumn > text.length) append(" ".repeat(cursorColumn - text.length))
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { append(predicted) }
withStyle(
SpanStyle(color = palette.foreground.copy(alpha = UNSENT_ALPHA), textDecoration = TextDecoration.Underline),
) {
append(unsent)
}
if (showCursor) append(CURSOR_GLYPH)
val after = split + predicted.length + unsent.length
if (after < text.length) append(full.subSequence(after, text.length))
}
@@ -29,6 +29,8 @@ sealed interface NodeDetailAction {
/** Open the remote-administration screen, ensuring a fresh session passkey first. */
data class OpenRemoteAdmin(val nodeNum: Int) : NodeDetailAction
data class OpenRemoteShell(val nodeNum: Int) : NodeDetailAction
/** Force-refresh device metadata (firmware version, edition, role) for the given node. */
data class RefreshMetadata(val nodeNum: Int) : NodeDetailAction
@@ -48,10 +48,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
@@ -70,6 +72,8 @@ 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.TracerouteMapScreen
import org.meshtastic.feature.node.metrics.terminal.RemoteShellScreen
import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel
import kotlin.reflect.KClass
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -140,6 +144,16 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(backStack: NavBackStack<NavKey>)
)
}
// RemoteShell uses its own ViewModel and is wired up separately from the MetricsViewModel-based screens. No pane
// metadata: a terminal wants the whole window, so it takes the single-pane fallback instead of the extra pane.
entry<NodeDetailRoute.RemoteShell> { args ->
val remoteShellViewModel = koinViewModel<RemoteShellViewModel> { parametersOf(args.destNum) }
RemoteShellScreen(
viewModel = remoteShellViewModel,
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
NodeDetailScreen.entries.forEach { routeInfo ->
when (routeInfo.routeClass) {
NodeDetailRoute.DeviceMetrics::class ->
@@ -169,6 +183,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(backStack: NavBackStack<NavKey>)
NodeDetailRoute.NeighborInfoLog::class ->
addNodeDetailScreenComposable<NodeDetailRoute.NeighborInfoLog>(backStack, routeInfo) { it.destNum }
// NodeDetailRoute.RemoteShell is handled by the dedicated entry above.
else -> Unit
}
}
@@ -257,4 +272,12 @@ enum class NodeDetailScreen(
Res.drawable.ic_air,
{ metricsVM, onNavigateUp -> AirQualityMetricsScreen(metricsVM, onNavigateUp) },
),
REMOTE_SHELL(
Res.string.remote_shell,
NodeDetailRoute.RemoteShell::class,
Res.drawable.ic_terminal,
// Navigation for RemoteShell is handled by a dedicated entry<NodeDetailRoute.RemoteShell>
// block in nodeDetailGraph() that resolves RemoteShellViewModel instead of MetricsViewModel.
{ _, _ -> },
),
}
@@ -0,0 +1,142 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
private fun printed(text: String) = text.map { EchoEvent.Printed(it) }
/** A line whose first character the remote has echoed, so predictions on it are drawn. */
private fun confirmedEcho(): LocalEcho = LocalEcho().apply {
onSent("e", 0)
onOutput(printed("e"), 50)
}
class LocalEchoTest {
@Test
fun nothingIsDrawnUntilTheLineHasEchoedOnce() {
val echo = LocalEcho()
echo.onSent("ls -l", 0)
assertEquals("", echo.pending)
assertFalse(echo.showsTyping)
echo.onOutput(printed("l"), 100)
assertEquals("s -l", echo.pending)
assertTrue(echo.showsTyping)
}
@Test
fun sentCharactersArePredictedUntilEchoed() {
val echo = confirmedEcho()
echo.onSent("cho hi", 100)
assertEquals("cho hi", echo.pending)
echo.onOutput(printed("cho"), 200)
assertEquals(" hi", echo.pending)
echo.onOutput(printed(" hi"), 300)
assertEquals("", echo.pending)
}
@Test
fun aPasswordTypedAtAPromptThatDoesNotEchoIsNeverDrawn() {
val echo = LocalEcho()
echo.onSent("sudo -v\r", 0)
echo.onSent("hunter2", 500)
assertEquals("", echo.pending)
echo.tick(PREDICTION_TIMEOUT_MIN_MS + 500, roundTripMs = null)
assertEquals("", echo.pending)
assertFalse(echo.showsTyping)
}
@Test
fun enterStartsAnUnconfirmedLine() {
val echo = confirmedEcho()
echo.onSent("\rpass", 100)
assertEquals("", echo.pending)
}
@Test
fun aDivergingEchoDropsThePredictionAndTheConfirmation() {
val echo = confirmedEcho()
echo.onSent("ab", 100)
echo.onOutput(printed("ax"), 200)
assertEquals("", echo.pending)
echo.onSent("c", 300)
assertEquals("", echo.pending)
}
@Test
fun backspaceRetractsThePredictionAndItsEchoPasses() {
val echo = confirmedEcho()
echo.onSent("abc\u007f", 100)
assertEquals("ab", echo.pending)
echo.onOutput(printed("ab"), 200)
echo.onOutput(listOf(EchoEvent.Printed('c'), EchoEvent.Edited, EchoEvent.Printed(' '), EchoEvent.Edited), 220)
assertEquals("", echo.pending)
}
@Test
fun aControlKeyDropsThePrediction() {
val echo = confirmedEcho()
echo.onSent("cd /us", 100)
echo.onSent("\t", 110)
assertEquals("", echo.pending)
}
@Test
fun anUnconfirmedLineTimesOutAndStaysOffUntilEnter() {
val echo = confirmedEcho()
echo.onSent("xyz", 100)
echo.tick(100 + PREDICTION_TIMEOUT_MIN_MS, roundTripMs = null)
assertEquals("", echo.pending)
echo.onSent("more", PREDICTION_TIMEOUT_MIN_MS + 200)
echo.onOutput(printed("m"), PREDICTION_TIMEOUT_MIN_MS + 300)
assertEquals("", echo.pending)
}
@Test
fun aConfirmedCharacterRestartsTheWait() {
val echo = confirmedEcho()
echo.onSent("abc", 100)
echo.onOutput(printed("a"), PREDICTION_TIMEOUT_MIN_MS)
echo.tick(PREDICTION_TIMEOUT_MIN_MS + 100, roundTripMs = null)
assertEquals("bc", echo.pending)
}
@Test
fun theTimeoutScalesWithTheRoundTrip() {
val echo = confirmedEcho()
echo.onSent("x", 100)
echo.tick(100 + PREDICTION_TIMEOUT_MIN_MS + 1, roundTripMs = 3_000)
assertEquals("x", echo.pending)
echo.tick(100 + 9_000, roundTripMs = 3_000)
assertEquals("", echo.pending)
}
@Test
fun secretPromptsAreRecognised() {
assertTrue(looksLikeSecretPrompt("[sudo] password for james: "))
assertTrue(looksLikeSecretPrompt("Password:"))
assertTrue(looksLikeSecretPrompt("Enter passphrase for key '/root/.ssh/id_ed25519': "))
assertTrue(looksLikeSecretPrompt("Enter PIN: "))
assertFalse(looksLikeSecretPrompt("root@node:~# "))
assertFalse(looksLikeSecretPrompt("# cat password.txt"))
assertFalse(looksLikeSecretPrompt("Pinging host: "))
}
}
@@ -0,0 +1,223 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.proto.RemoteShell
import org.meshtastic.proto.RemoteShell.OpCode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
private const val SID = 0x1234
private fun server(
op: OpCode,
seq: Int,
ack: Int = 0,
payload: String = "",
lastRx: Int = 0,
lastTx: Int = 0,
session: Int = SID,
): RemoteShell = shellFrame(op) { wb ->
wb.session_id = session
wb.seq = seq
wb.ack_seq = ack
wb.payload = payload.encodeUtf8()
wb.last_rx_seq = lastRx
wb.last_tx_seq = lastTx
}
/** OPEN at t=0, OPEN_OK (seq 1, acking our OPEN) at t=100. */
private fun openedLink(): RemoteShellLink = RemoteShellLink(SID).apply {
open(cols = 80, rows = 24, nowMs = 0)
receive(server(OpCode.OPEN_OK, seq = 1, ack = 1), nowMs = 100)
}
private fun ShellStep.only(op: OpCode) = send.filter { it.op == op }
private fun typed(bytes: Int) = "x".repeat(bytes).encodeUtf8()
class RemoteShellLinkTest {
@Test
fun openCarriesTheTerminalSizeAsSequenceOne() {
val step = RemoteShellLink(SID).open(cols = 54, rows = 29, nowMs = 0)
val open = step.send.single()
assertEquals(OpCode.OPEN, open.op)
assertEquals(1, open.seq)
assertEquals(54, open.cols)
assertEquals(29, open.rows)
assertEquals(0, open.flags)
}
@Test
fun openOkOpensTheSession() {
val link = RemoteShellLink(SID)
link.open(80, 24, 0)
val step = link.receive(server(OpCode.OPEN_OK, seq = 1, ack = 1), 100)
assertTrue(link.isOpen)
assertEquals(listOf<ShellEvent>(ShellEvent.Opened), step.events)
}
@Test
fun aOneWayStreamIsAcknowledgedEverySecondFrame() {
val link = openedLink()
val first = link.receive(server(OpCode.OUTPUT, seq = 2, ack = 1, payload = "a"), 200)
val ack = first.only(OpCode.ACK).single()
assertEquals(2, ack.ack_seq)
assertEquals(0, ack.last_rx_seq)
assertTrue(link.receive(server(OpCode.OUTPUT, seq = 3, ack = 1, payload = "b"), 300).send.isEmpty())
assertEquals(4, link.receive(server(OpCode.OUTPUT, seq = 4, ack = 1, payload = "c"), 400).send.single().ack_seq)
}
@Test
fun aDuplicateIsAnsweredWithTheCursor() {
val link = openedLink()
link.receive(server(OpCode.OUTPUT, seq = 2, ack = 1, payload = "a"), 200)
val ack = link.receive(server(OpCode.OUTPUT, seq = 2, ack = 1, payload = "a"), 1_200).send.single()
assertEquals(OpCode.ACK, ack.op)
assertEquals(2, ack.ack_seq)
assertEquals(0, ack.last_rx_seq)
}
@Test
fun aGapAsksForTheMissingFrameAndDeliversInOrderOnceFilled() {
val link = openedLink()
val gap = link.receive(server(OpCode.OUTPUT, seq = 3, ack = 1, payload = "c"), 200)
assertEquals(1, gap.only(OpCode.ACK).single().last_rx_seq)
assertTrue(gap.events.isEmpty())
val filled = link.receive(server(OpCode.OUTPUT, seq = 2, ack = 1, payload = "b"), 300)
assertEquals(
listOf("b", "c"),
filled.events.filterIsInstance<ShellEvent.Output>().map { it.bytes.utf8() },
)
}
@Test
fun aReplayRequestResendsThatFrame() {
val link = openedLink()
link.input("ls\r".encodeUtf8(), 200)
val replay = link.receive(server(OpCode.ACK, seq = 0, ack = 1, lastRx = 1), 300).send.single()
assertEquals(OpCode.INPUT, replay.op)
assertEquals(2, replay.seq)
assertEquals("ls\r", replay.payload.utf8())
}
@Test
fun inputBeyondTheWindowWaitsForTheCursor() {
val link = openedLink()
val burst = link.input(typed(MAX_INPUT_CHUNK_BYTES * 6), 200)
assertEquals(listOf(2, 3, 4, 5), burst.only(OpCode.INPUT).map { it.seq })
val reopened = link.receive(server(OpCode.OUTPUT, seq = 2, ack = 3, payload = "x"), 300)
assertEquals(listOf(6, 7), reopened.only(OpCode.INPUT).map { it.seq })
}
@Test
fun aShutWindowRetransmitsItsOldestFrameThenGivesUp() {
val link = openedLink()
link.input(typed(MAX_INPUT_CHUNK_BYTES * 4), 200)
var resends = 0
var closed: ShellEvent.Closed? = null
var sentClose = false
var now = 250L
while (now < 600_000 && closed == null) {
val step = link.tick(now)
resends += step.send.count { it.op == OpCode.INPUT && it.seq == 2 }
sentClose = sentClose || step.send.any { it.op == OpCode.CLOSE }
closed = step.events.filterIsInstance<ShellEvent.Closed>().firstOrNull()
now += 250
}
assertEquals(MAX_RETRANSMITS, resends)
assertTrue(sentClose)
assertTrue(link.isClosed)
}
@Test
fun aMissingOpenOkIsRecoveredByRepeatingOpenThenTimesOut() {
val link = RemoteShellLink(SID)
link.open(80, 24, 0)
val opens = mutableListOf<Long>()
var closed = false
var now = 250L
while (!closed) {
val step = link.tick(now)
step.only(OpCode.OPEN).forEach {
assertEquals(1, it.seq)
opens += now
}
closed = step.events.any { it is ShellEvent.Closed }
now += 250
}
assertEquals(listOf(5_000L, 15_000L, 35_000L), opens)
assertEquals(OPEN_TIMEOUT_MS, now - 250)
}
@Test
fun closedIsActedOnOutOfOrder() {
val link = openedLink()
val step = link.receive(server(OpCode.CLOSED, seq = 9, ack = 1, payload = "pty_eof"), 200)
assertEquals(listOf<ShellEvent>(ShellEvent.Closed("pty_eof")), step.events)
assertTrue(link.isClosed)
}
@Test
fun aSequencedErrorKeepsTheSessionAndASessionlessOneEndsIt() {
val link = openedLink()
val error = link.receive(server(OpCode.ERROR, seq = 2, ack = 1, payload = "unsupported_op"), 200)
assertEquals(listOf<ShellEvent>(ShellEvent.RemoteError("unsupported_op")), error.events)
assertFalse(link.isClosed)
link.receive(server(OpCode.ERROR, seq = 0, payload = "invalid_session"), 300)
assertTrue(link.isClosed)
}
@Test
fun inboundSilenceSendsAPingWithBothCursors() {
val link = openedLink()
assertTrue(link.tick(5_000).send.isEmpty())
val ping = link.tick(5_100).only(OpCode.PING).single()
assertEquals(1, ping.last_rx_seq)
assertEquals(1, ping.last_tx_seq)
assertTrue(link.tick(5_350).only(OpCode.PING).isEmpty())
assertEquals(1, link.tick(20_100).only(OpCode.PING).size)
}
@Test
fun healthReportsHowLongTheOldestUnacknowledgedFrameHasWaited() {
val link = openedLink()
assertEquals(null, link.health.waitingSinceMs)
link.input("x".encodeUtf8(), 300)
assertEquals(300L, link.health.waitingSinceMs)
assertEquals(1, link.health.unacknowledged)
link.receive(server(OpCode.OUTPUT, seq = 2, ack = 2, payload = "x"), 450)
assertEquals(null, link.health.waitingSinceMs)
assertEquals(450L, link.health.lastInboundMs)
}
@Test
fun framesForAnotherSessionAreIgnored() {
val link = openedLink()
val step = link.receive(server(OpCode.OUTPUT, seq = 2, payload = "x", session = SID + 1), 200)
assertTrue(step.send.isEmpty() && step.events.isEmpty())
}
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import kotlin.test.Test
import kotlin.test.assertEquals
class TerminalKeysTest {
@Test
fun cursorKeysFollowTheApplicationCursorMode() {
assertEquals("\u001b[A", TerminalKey.UP.sequence(applicationCursorKeys = false))
assertEquals("\u001bOA", TerminalKey.UP.sequence(applicationCursorKeys = true))
assertEquals("\u001b[5~", TerminalKey.PAGE_UP.sequence(applicationCursorKeys = true))
}
@Test
fun ctrlMapsLettersAndPunctuationToControlCodes() {
val ctrl = Modifiers(ctrl = ModifierState.ONCE)
assertEquals("\u0003", 'c'.withModifiers(ctrl))
assertEquals("\u0003", 'C'.withModifiers(ctrl))
assertEquals("\u001b", '['.withModifiers(ctrl))
assertEquals("\u007f", '?'.withModifiers(ctrl))
assertEquals("1", '1'.withModifiers(ctrl))
}
@Test
fun altPrefixesEscape() {
assertEquals("\u001bb", 'b'.withModifiers(Modifiers(alt = ModifierState.ONCE)))
assertEquals("\u001b\u0002", 'b'.withModifiers(Modifiers(ModifierState.ONCE, ModifierState.LOCKED)))
}
@Test
fun aOneShotModifierReleasesAndALockedOneStays() {
val mods = Modifiers(ctrl = ModifierState.ONCE, alt = ModifierState.LOCKED).consumed()
assertEquals(Modifiers(ctrl = ModifierState.OFF, alt = ModifierState.LOCKED), mods)
}
@Test
fun aTypedCharacterIsInsertedText() {
assertEquals(SinkEdit(deleted = 0, inserted = "a"), sinkEdit(listOf(SinkChange(0, "a"))))
assertEquals(SinkEdit(deleted = 0, inserted = "ls -l\n"), sinkEdit(listOf(SinkChange(0, "ls -l\n"))))
}
@Test
fun aDeletionWithNothingInsertedIsABackspace() {
assertEquals(SinkEdit(deleted = 1, inserted = ""), sinkEdit(listOf(SinkChange(1, ""))))
}
@Test
fun replacingTheSentinelSendsNoBackspaceAndKeepsALeadingSpace() {
assertEquals(SinkEdit(deleted = 0, inserted = "x"), sinkEdit(listOf(SinkChange(1, "x"))))
assertEquals(SinkEdit(deleted = 0, inserted = " foo"), sinkEdit(listOf(SinkChange(1, " foo"))))
}
@Test
fun noChangesTypeNothing() {
assertEquals(SinkEdit(deleted = 0, inserted = ""), sinkEdit(emptyList()))
}
@Test
fun aTapCyclesOffOnceLocked() {
assertEquals(ModifierState.ONCE, ModifierState.OFF.next())
assertEquals(ModifierState.LOCKED, ModifierState.ONCE.next())
assertEquals(ModifierState.OFF, ModifierState.LOCKED.next())
}
}
@@ -0,0 +1,190 @@
/*
* Copyright (c) 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics.terminal
import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
private const val E = "\u001b"
private fun TerminalOutput.feed(text: String) = append(text.encodeUtf8())
private fun TerminalOutput.texts() = lines().map { it.text }
class TerminalOutputTest {
@Test
fun framesSplitMidLineJoinIntoOneLine() {
val out = TerminalOutput(maxLines = 10)
out.feed("$ wh")
out.feed("oami\r\njames\r\n$ ")
assertEquals(listOf("$ whoami", "james", "$ "), out.texts())
assertEquals(2, out.cursorColumn)
}
@Test
fun aCharacterSplitAcrossFramesDecodesWhole() {
val out = TerminalOutput(maxLines = 10)
val bytes = "°C".encodeUtf8().toByteArray()
out.append(bytes.copyOfRange(0, 1).toByteString())
assertEquals(listOf(""), out.texts())
out.append(bytes.copyOfRange(1, bytes.size).toByteString())
assertEquals(listOf("°C"), out.texts())
}
@Test
fun backspaceEchoErasesTheLastCharacter() {
val out = TerminalOutput(maxLines = 10)
out.feed("lss\b \b")
assertEquals(listOf("ls "), out.texts())
assertEquals(2, out.cursorColumn)
}
@Test
fun carriageReturnOverwritesLikeAProgressBar() {
val out = TerminalOutput(maxLines = 10)
out.feed(" 10%\r 55%\r100%\n")
assertEquals(listOf("100%", ""), out.texts())
}
@Test
fun eraseInLineClearsFromTheCursor() {
val out = TerminalOutput(maxLines = 10)
out.feed("hello world\r$E[6C$E[K")
assertEquals(listOf("hello "), out.texts())
}
@Test
fun sgrColoursBecomeStyleRuns() {
val out = TerminalOutput(maxLines = 10)
out.feed("$E[1;32mok$E[0m plain $E[38;5;208mx$E[38;2;1;2;3my$E[m")
val line = out.lines().single()
assertEquals("ok plain xy", line.text)
assertEquals(
listOf(
StyleRun(0, 2, CellStyle(fg = 2, bold = true)),
StyleRun(9, 10, CellStyle(fg = 208)),
StyleRun(10, 11, CellStyle(fg = TRUECOLOR_FLAG or 0x010203)),
),
line.runs,
)
}
@Test
fun aSequenceSplitAcrossFramesStillApplies() {
val out = TerminalOutput(maxLines = 10)
out.feed("$E[3")
out.feed("1mred")
assertEquals(listOf(StyleRun(0, 3, CellStyle(fg = 1))), out.lines().single().runs)
}
@Test
fun titleAndOtherStringsAreConsumed() {
val out = TerminalOutput(maxLines = 10)
out.feed("$E]0;user@host: ~\u0007${E}P1\$r${E}\\$E(B$ ")
assertEquals(listOf("$ "), out.texts())
}
@Test
fun clearEmptiesTheScreen() {
val out = TerminalOutput(maxLines = 10)
out.feed("one\r\ntwo\r\n$E[H$E[2J$ ")
assertEquals(listOf("$ "), out.texts())
}
@Test
fun fullScreenAndCursorKeyModesAreReported() {
val out = TerminalOutput(maxLines = 10)
assertTrue(out.feed("$E[?1049h$E[?1h").enteredFullScreen)
assertTrue(out.applicationCursorKeys)
assertFalse(out.feed("$E[?1l").enteredFullScreen)
assertFalse(out.applicationCursorKeys)
}
@Test
fun bellIsReportedNotPrinted() {
val out = TerminalOutput(maxLines = 10)
assertTrue(out.feed("a\u0007b").bell)
assertEquals(listOf("ab"), out.texts())
}
@Test
fun tabsAdvanceToTheNextStop() {
val out = TerminalOutput(maxLines = 10)
out.feed("a\tb")
assertEquals(listOf("a b"), out.texts())
}
@Test
fun aNoticeTakesItsOwnLineAndLeavesAFreshOne() {
val out = TerminalOutput(maxLines = 10)
out.feed("$ ")
out.notice("Session closed")
assertEquals(listOf("$ ", "Session closed", ""), out.texts())
assertTrue(out.lines()[1].isNotice)
}
@Test
fun oldestLinesAreDroppedPastTheLimit() {
val out = TerminalOutput(maxLines = 3)
out.feed("1\n2\n3\n4")
assertEquals(listOf("2", "3", "4"), out.texts())
}
@Test
fun aHugeCursorMoveIsClampedToTheLineWidth() {
val out = TerminalOutput(maxLines = 10)
out.feed("$E[999999999Cx")
val line = out.lines().last()
assertEquals(MAX_LINE_WIDTH, line.text.length)
assertEquals('x', line.text.last())
}
@Test
fun printingPastTheLineWidthWraps() {
val out = TerminalOutput(maxLines = 10)
out.feed("$E[${MAX_LINE_WIDTH}Gab")
assertEquals(listOf(MAX_LINE_WIDTH, 1), out.lines().map { it.text.length })
}
@Test
fun aHugeInsertCountIsClampedToTheLineWidth() {
val out = TerminalOutput(maxLines = 10)
out.feed("abc\r$E[999999999@")
assertEquals(MAX_LINE_WIDTH, out.lines().last().text.length)
}
@Test
fun printedCharactersAreReportedForEchoMatching() {
val out = TerminalOutput(maxLines = 10)
val echo = out.feed("ab\b \b").echo
assertEquals(
listOf(
EchoEvent.Printed('a'),
EchoEvent.Printed('b'),
EchoEvent.Edited,
EchoEvent.Printed(' '),
EchoEvent.Edited,
),
echo,
)
}
}