feat(remote-shell): size the PTY from the viewport, make output selectable

OPEN and RESIZE carried a hardcoded 80x24, so the remote shell wrapped to
a width the phone does not have. The viewport is now measured in monospace
cells and the session waits for that measurement, so OPEN carries the real
size - 'stty size' on a medium_phone emulator reports 29 54.

Output sits in a SelectionContainer, so a command's output can be copied
out instead of retyped.
This commit is contained in:
James Rich committed 2026-08-24 14:51:59 -05:00
1 parent 5a8028172c
commit e981e8e4df
1 file changed
+41 -12
@@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -52,8 +53,11 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
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
@@ -88,7 +92,16 @@ fun RemoteShellScreen(viewModel: RemoteShellViewModel, onNavigateUp: () -> Unit,
val pendingInput by viewModel.pendingInput.collectAsStateWithLifecycle()
val sessionState by viewModel.sessionState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.openSession() }
// 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)
LaunchedEffect(cols, rows) {
if (cols <= 0 || rows <= 0) return@LaunchedEffect
viewModel.resize(cols, rows)
viewModel.openSession()
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
@@ -98,8 +111,7 @@ fun RemoteShellScreen(viewModel: RemoteShellViewModel, onNavigateUp: () -> Unit,
}
val listState = rememberLazyListState()
val lastIndex = outputLines.size // pending-input row sits one past the output
LaunchedEffect(lastIndex, pendingInput) { if (lastIndex >= 0) listState.animateScrollToItem(lastIndex) }
LaunchedEffect(outputLines.size, pendingInput) { listState.animateScrollToItem(outputLines.size) }
Scaffold(
modifier = modifier,
@@ -119,15 +131,20 @@ fun RemoteShellScreen(viewModel: RemoteShellViewModel, onNavigateUp: () -> Unit,
Column(modifier = Modifier.fillMaxSize().padding(paddingValues).imePadding()) {
SessionStatusBar(state = sessionState, onReconnect = { viewModel.openSession() })
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
state = listState,
modifier =
Modifier.fillMaxSize().padding(TERMINAL_PADDING).clickable { focusRequester.requestFocus() },
) {
// The caret belongs after the prompt, not under it, so the trailing line carries the pending input.
items(outputLines.dropLast(1)) { line -> TerminalLine(text = line) }
item { TerminalLine(text = outputLines.lastOrNull().orEmpty() + pendingInput + CURSOR) }
Box(modifier = Modifier.weight(1f).fillMaxWidth().onSizeChanged { terminalSize = it }) {
SelectionContainer {
LazyColumn(
state = listState,
modifier =
Modifier.fillMaxSize().padding(TERMINAL_PADDING).clickable {
focusRequester.requestFocus()
},
) {
// The caret belongs after the prompt, not under it, so the trailing line carries the pending
// input.
items(outputLines.dropLast(1)) { line -> TerminalLine(text = line) }
item { TerminalLine(text = outputLines.lastOrNull().orEmpty() + pendingInput + CURSOR) }
}
}
KeyboardSink(
@@ -271,6 +288,18 @@ private val CONTROL_KEYS =
"\u2193" to "\u001b[B",
)
/** Viewport size in monospace cells, so the remote PTY can be told how wide to wrap. */
@Composable
private fun rememberTerminalGrid(size: IntSize): Pair<Int, Int> {
val textMeasurer = rememberTextMeasurer()
val cell =
remember(textMeasurer) {
textMeasurer.measure("0", TextStyle(fontFamily = FontFamily.Monospace, fontSize = TERMINAL_FONT_SIZE)).size
}
if (cell.width <= 0 || cell.height <= 0) return 0 to 0
return (size.width / cell.width) to (size.height / cell.height)
}
@Composable
private fun TerminalLine(text: String, color: Color = MaterialTheme.colorScheme.onSurface) {
Text(