diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md index 65fd4d8..763166d 100644 --- a/PROJECT_SPEC.md +++ b/PROJECT_SPEC.md @@ -182,6 +182,13 @@ structured composer is deferred interaction polish; a possible set is: Do not reproduce a full desktop keyboard. +When the software keyboard leaves a compact-height raw-terminal viewport, the connected header +collapses to one row while keeping the host identity, connection state, and Transcript action +visible. Home, Diagnostics, Disconnect, Ctrl-C, and the terminal-key-row toggle remain available +through More. The terminal-key row is hidden by default in this state so at least one terminal row +remains readable; opening More must not dismiss the keyboard. The normal portrait layout is +unchanged. + ### 4.4 Command card Each submitted command creates one card. diff --git a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt index 9b0b80e..d50cd12 100644 --- a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt +++ b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt @@ -8,18 +8,23 @@ import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.test.SemanticsMatcher @@ -266,7 +271,7 @@ class TranscriptScreenTest { }, onControlC = {}, onDisconnect = {}, - rawTerminal = { modifier -> + rawTerminal = { modifier, _ -> Text("Raw draft test surface", modifier = modifier) }, ) @@ -681,7 +686,7 @@ class TranscriptScreenTest { }, onControlC = {}, onDisconnect = {}, - rawTerminal = { modifier -> + rawTerminal = { modifier, _ -> Text("Raw terminal test surface", modifier = modifier) }, ) @@ -717,7 +722,7 @@ class TranscriptScreenTest { onControlC = {}, onDisconnect = { disconnectCount += 1 }, onOpenHome = { homeCount += 1 }, - rawTerminal = { modifier -> Text("Terminal", modifier = modifier) }, + rawTerminal = { modifier, _ -> Text("Terminal", modifier = modifier) }, ) } } @@ -763,7 +768,7 @@ class TranscriptScreenTest { }, onControlC = {}, onDisconnect = {}, - rawTerminal = { modifier -> + rawTerminal = { modifier, _ -> Text("Raw performance surface", modifier = modifier) }, ) @@ -973,7 +978,7 @@ class TranscriptScreenTest { onControlC = {}, onDisconnect = {}, onOpenDiagnostics = {}, - rawTerminal = { modifier -> Text("Terminal", modifier = modifier) }, + rawTerminal = { modifier, _ -> Text("Terminal", modifier = modifier) }, ) } } @@ -1007,6 +1012,130 @@ class TranscriptScreenTest { composeRule.onNodeWithText("Disconnect").performScrollTo().assertIsDisplayed() } + @Test + fun compactRawTerminalPreservesViewportAndGroupsSecondaryActionsAtLargeFontScale() { + var controlCCount = 0 + var homeCount = 0 + var diagnosticsCount = 0 + var disconnectCount = 0 + var minimumTerminalRowHeightPx = 0f + + composeRule.setContent { + val density = LocalDensity.current + minimumTerminalRowHeightPx = density.density * 24f + CompositionLocalProvider( + LocalDensity provides Density(density.density, fontScale = 2f), + ) { + MaterialTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + ) { + ConnectedSessionScreen( + displayName = "Compact terminal session", + structuredShell = StructuredShellState.Ready("/tmp"), + transcript = CommandTranscriptState(), + onSubmit = { + CommandSubmissionResult.Accepted(CommandId("unused")) + }, + onControlC = { controlCCount += 1 }, + onDisconnect = { disconnectCount += 1 }, + onOpenHome = { homeCount += 1 }, + onOpenDiagnostics = { diagnosticsCount += 1 }, + rawTerminal = { modifier, showExtraKeys -> + CompactTerminalTestSurface(modifier, showExtraKeys) + }, + ) + } + } + } + } + + composeRule.onNodeWithTag(TranscriptTags.MODE_SWITCH).performClick() + composeRule.onNodeWithTag(TranscriptTags.COMPACT_HEADER).assertIsDisplayed() + composeRule.onNodeWithText("Compact terminal session", useUnmergedTree = true) + .assertIsDisplayed() + composeRule.onNodeWithTag(TranscriptTags.MODE_SWITCH).assertIsDisplayed() + val terminalHeight = composeRule.onNodeWithTag(TranscriptTags.RAW_TERMINAL_VIEWPORT) + .assertIsDisplayed() + .fetchSemanticsNode() + .boundsInRoot + .height + assertTrue( + "Raw terminal viewport was only $terminalHeight px", + terminalHeight >= minimumTerminalRowHeightPx, + ) + + composeRule.onNodeWithTag(TranscriptTags.TERMINAL_CONTROL).assertDoesNotExist() + composeRule.onNodeWithTag(TranscriptTags.HOME).assertDoesNotExist() + composeRule.onNodeWithText("Diagnostics").assertDoesNotExist() + composeRule.onNodeWithText("Disconnect").assertDoesNotExist() + composeRule.onNodeWithTag(TranscriptTags.SESSION_OVERFLOW).performClick() + composeRule.onNodeWithText("Show terminal keys").assertIsDisplayed().performClick() + composeRule.onNodeWithTag(TranscriptTags.TERMINAL_CONTROL).assertIsDisplayed() + composeRule.onNodeWithTag(TranscriptTags.SESSION_OVERFLOW).performClick() + composeRule.onNodeWithText("Hide terminal keys").assertIsDisplayed().performClick() + composeRule.onNodeWithTag(TranscriptTags.TERMINAL_CONTROL).assertDoesNotExist() + composeRule.onNodeWithTag(TranscriptTags.SESSION_OVERFLOW).performClick() + composeRule.onNodeWithText("Ctrl-C").assertIsDisplayed().performClick() + composeRule.onNodeWithTag(TranscriptTags.SESSION_OVERFLOW).performClick() + composeRule.onNodeWithTag(TranscriptTags.HOME).assertIsDisplayed().performClick() + composeRule.onNodeWithTag(TranscriptTags.SESSION_OVERFLOW).performClick() + composeRule.onNodeWithText("Diagnostics").assertIsDisplayed().performClick() + composeRule.onNodeWithTag(TranscriptTags.SESSION_OVERFLOW).performClick() + composeRule.onNodeWithText("Disconnect").performScrollTo().assertIsDisplayed().performClick() + composeRule.runOnIdle { + assertEquals(1, controlCCount) + assertEquals(1, homeCount) + assertEquals(1, diagnosticsCount) + assertEquals(1, disconnectCount) + } + } + + @Test + fun compactHeaderTransitionKeepsRawTerminalCompositionAlive() { + val containerHeight = mutableStateOf(600.dp) + var rawTerminalDisposeCount = 0 + + composeRule.setContent { + MaterialTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .height(containerHeight.value), + ) { + ConnectedSessionScreen( + displayName = "Resize test session", + structuredShell = StructuredShellState.Ready("/tmp"), + transcript = CommandTranscriptState(), + onSubmit = { + CommandSubmissionResult.Accepted(CommandId("unused")) + }, + onControlC = {}, + onDisconnect = {}, + rawTerminal = { modifier, showExtraKeys -> + DisposableEffect(Unit) { + onDispose { rawTerminalDisposeCount += 1 } + } + CompactTerminalTestSurface(modifier, showExtraKeys) + }, + ) + } + } + } + + composeRule.onNodeWithTag(TranscriptTags.MODE_SWITCH).performClick() + composeRule.onNodeWithTag(TranscriptTags.COMPACT_HEADER).assertDoesNotExist() + composeRule.runOnIdle { containerHeight.value = 320.dp } + composeRule.onNodeWithTag(TranscriptTags.COMPACT_HEADER).assertIsDisplayed() + composeRule.runOnIdle { assertEquals(0, rawTerminalDisposeCount) } + composeRule.runOnIdle { containerHeight.value = 600.dp } + composeRule.onNodeWithTag(TranscriptTags.COMPACT_HEADER).assertDoesNotExist() + composeRule.onNodeWithTag(TranscriptTags.RAW_TERMINAL_VIEWPORT).assertIsDisplayed() + composeRule.runOnIdle { assertEquals(0, rawTerminalDisposeCount) } + } + @Test fun runningTurnShowsLiveDuration() { val turn = turn( @@ -1429,6 +1558,34 @@ class TranscriptScreenTest { .assertIsDisplayed() } + @androidx.compose.runtime.Composable + private fun CompactTerminalTestSurface( + modifier: Modifier, + showExtraKeys: Boolean, + ) { + Column(modifier = modifier) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .testTag(TranscriptTags.RAW_TERMINAL_VIEWPORT), + ) { + Text("Raw terminal test surface") + } + if (showExtraKeys) { + HorizontalDivider() + TerminalExtraKeyRow( + modifiers = TerminalModifiers(), + onToggleControl = {}, + onToggleAlt = {}, + onKey = {}, + onShowKeyboard = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + private fun runningShell() = StructuredShellState.Running( activeCommand = ActiveCommand( id = CommandId("command-42"), diff --git a/app/src/main/java/dev/threadline/TranscriptScreen.kt b/app/src/main/java/dev/threadline/TranscriptScreen.kt index b0f31c6..b92b9b7 100644 --- a/app/src/main/java/dev/threadline/TranscriptScreen.kt +++ b/app/src/main/java/dev/threadline/TranscriptScreen.kt @@ -8,13 +8,17 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState @@ -57,6 +61,7 @@ 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.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.LiveRegionMode @@ -73,7 +78,9 @@ import androidx.compose.ui.text.font.FontFamily 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.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandSubmissionRejection import dev.threadline.core.shell.CommandSubmissionResult @@ -106,6 +113,9 @@ internal object TranscriptTags { const val TERMINAL_KEYBOARD = "terminal-keyboard" const val SUBMISSION_ERROR = "command-submission-error" const val SESSION_ACTIONS = "session-actions" + const val SESSION_OVERFLOW = "session-overflow" + const val COMPACT_HEADER = "session-compact-header" + const val RAW_TERMINAL_VIEWPORT = "raw-terminal-viewport" const val HOME = "session-home" private const val TERMINAL_KEY_PREFIX = "terminal-key-" private const val OUTPUT_PREFIX = "command-output-" @@ -138,6 +148,8 @@ private val terminalExtraKeys = listOf( TerminalExtraKey(TerminalKey.DELETE, "Del", "Delete"), ) +private val CompactRawSessionHeight = 360.dp + @Composable internal fun ConnectedSessionScreen( displayName: String, @@ -149,9 +161,12 @@ internal fun ConnectedSessionScreen( onDisconnect: () -> Unit, onOpenHome: () -> Unit = {}, onOpenDiagnostics: () -> Unit = {}, - rawTerminal: @Composable (Modifier) -> Unit = { RawTerminal(it) }, + rawTerminal: @Composable (Modifier, Boolean) -> Unit = { modifier, showExtraKeys -> + RawTerminal(modifier, showExtraKeys) + }, ) { var rawModeRequested by rememberSaveable { mutableStateOf(false) } + var compactTerminalKeysVisible by rememberSaveable { mutableStateOf(false) } val transcriptStateHolder = rememberSaveableStateHolder() val rawModeRequired = structuredShell is StructuredShellState.Unavailable val showingRawTerminal = rawModeRequested @@ -160,88 +175,213 @@ internal fun ConnectedSessionScreen( if (rawModeRequired) rawModeRequested = true } - Scaffold( - topBar = { - Column(modifier = Modifier.statusBarsPadding()) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), + BoxWithConstraints { + val density = LocalDensity.current + val imeHeight = with(density) { WindowInsets.ime.getBottom(this).toDp() } + val compactHeader = showingRawTerminal && + (maxHeight - imeHeight).coerceAtLeast(0.dp) <= CompactRawSessionHeight + + LaunchedEffect(compactHeader) { + if (!compactHeader) compactTerminalKeysVisible = false + } + + Scaffold( + topBar = { + ConnectedSessionHeader( + displayName = displayName, + status = structuredShell.statusLabel(), + showingRawTerminal = showingRawTerminal, + canSwitchMode = !rawModeRequired || transcript.turns.isNotEmpty(), + compact = compactHeader, + compactTerminalKeysVisible = compactTerminalKeysVisible, + onSwitchMode = { rawModeRequested = !rawModeRequested }, + onToggleTerminalKeys = { + compactTerminalKeysVisible = !compactTerminalKeysVisible + }, + onControlC = onControlC, + onOpenHome = onOpenHome, + onOpenDiagnostics = onOpenDiagnostics, + onDisconnect = onDisconnect, + ) + }, + ) { contentPadding -> + if (showingRawTerminal) { + rawTerminal( + Modifier + .padding(contentPadding) + .fillMaxSize(), + !compactHeader || compactTerminalKeysVisible, + ) + } else { + transcriptStateHolder.SaveableStateProvider("transcript") { + TranscriptSurface( + structuredShell = structuredShell, + transcript = transcript, + onSubmit = onSubmit, + onSubmitIsolated = onSubmitIsolated, + onStop = onControlC, + onDisconnect = onDisconnect, + onOpenTerminal = { rawModeRequested = true }, modifier = Modifier - .weight(1f) - .padding(start = 16.dp, top = 8.dp, bottom = 8.dp), - ) { - Text( - displayName, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.semantics { heading() }, - ) - Text( - structuredShell.statusLabel(), - style = MaterialTheme.typography.labelSmall, - ) - } - if (!rawModeRequired || transcript.turns.isNotEmpty()) { - TextButton( - onClick = { rawModeRequested = !rawModeRequested }, - modifier = Modifier - .padding(end = 8.dp) - .testTag(TranscriptTags.MODE_SWITCH), - ) { - Text(if (showingRawTerminal) "Transcript" else "Terminal") - } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 8.dp) - .testTag(TranscriptTags.SESSION_ACTIONS), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (showingRawTerminal) { - TextButton(onClick = onControlC) { Text("Ctrl-C") } - } - TextButton( - onClick = onOpenHome, - modifier = Modifier.testTag(TranscriptTags.HOME), - ) { - Text("Home") - } - TextButton( - onClick = onOpenDiagnostics, - modifier = Modifier.testTag(DiagnosticTags.OPEN), - ) { - Text("Diagnostics") - } - TextButton(onClick = onDisconnect) { Text("Disconnect") } + .padding(contentPadding) + .fillMaxSize(), + ) } - HorizontalDivider() } - }, - ) { contentPadding -> - if (showingRawTerminal) { - rawTerminal( - Modifier - .padding(contentPadding) - .fillMaxSize(), + } + } +} + +@Composable +private fun ConnectedSessionHeader( + displayName: String, + status: String, + showingRawTerminal: Boolean, + canSwitchMode: Boolean, + compact: Boolean, + compactTerminalKeysVisible: Boolean, + onSwitchMode: () -> Unit, + onToggleTerminalKeys: () -> Unit, + onControlC: () -> Unit, + onOpenHome: () -> Unit, + onOpenDiagnostics: () -> Unit, + onDisconnect: () -> Unit, +) { + Column(modifier = Modifier.statusBarsPadding()) { + if (compact) { + CompactConnectedSessionHeader( + displayName = displayName, + status = status, + showingRawTerminal = showingRawTerminal, + canSwitchMode = canSwitchMode, + terminalKeysVisible = compactTerminalKeysVisible, + onSwitchMode = onSwitchMode, + onToggleTerminalKeys = onToggleTerminalKeys, + onControlC = onControlC, + onOpenHome = onOpenHome, + onOpenDiagnostics = onOpenDiagnostics, + onDisconnect = onDisconnect, ) } else { - transcriptStateHolder.SaveableStateProvider("transcript") { - TranscriptSurface( - structuredShell = structuredShell, - transcript = transcript, - onSubmit = onSubmit, - onSubmitIsolated = onSubmitIsolated, - onStop = onControlC, - onDisconnect = onDisconnect, - onOpenTerminal = { rawModeRequested = true }, - modifier = Modifier - .padding(contentPadding) - .fillMaxSize(), + ExpandedConnectedSessionHeader( + displayName = displayName, + status = status, + showingRawTerminal = showingRawTerminal, + canSwitchMode = canSwitchMode, + onSwitchMode = onSwitchMode, + onControlC = onControlC, + onOpenHome = onOpenHome, + onOpenDiagnostics = onOpenDiagnostics, + onDisconnect = onDisconnect, + ) + } + HorizontalDivider() + } +} + +@Composable +private fun CompactConnectedSessionHeader( + displayName: String, + status: String, + showingRawTerminal: Boolean, + canSwitchMode: Boolean, + terminalKeysVisible: Boolean, + onSwitchMode: () -> Unit, + onToggleTerminalKeys: () -> Unit, + onControlC: () -> Unit, + onOpenHome: () -> Unit, + onOpenDiagnostics: () -> Unit, + onDisconnect: () -> Unit, +) { + var overflowExpanded by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .testTag(TranscriptTags.COMPACT_HEADER), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .weight(1f) + .padding(start = 12.dp, top = 4.dp, bottom = 4.dp), + ) { + Text( + displayName, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { heading() }, + ) + Text( + status, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (canSwitchMode) { + TextButton( + onClick = onSwitchMode, + modifier = Modifier.testTag(TranscriptTags.MODE_SWITCH), + ) { + Text(if (showingRawTerminal) "Transcript" else "Terminal") + } + } + Box { + TextButton( + onClick = { overflowExpanded = true }, + modifier = Modifier.testTag(TranscriptTags.SESSION_OVERFLOW), + ) { + Text("More") + } + DropdownMenu( + expanded = overflowExpanded, + onDismissRequest = { overflowExpanded = false }, + properties = PopupProperties(focusable = false), + ) { + DropdownMenuItem( + text = { + Text(if (terminalKeysVisible) "Hide terminal keys" else "Show terminal keys") + }, + onClick = { + overflowExpanded = false + onToggleTerminalKeys() + }, + ) + if (showingRawTerminal) { + DropdownMenuItem( + text = { Text("Ctrl-C") }, + onClick = { + overflowExpanded = false + onControlC() + }, + ) + } + DropdownMenuItem( + text = { Text("Home") }, + onClick = { + overflowExpanded = false + onOpenHome() + }, + modifier = Modifier.testTag(TranscriptTags.HOME), + ) + DropdownMenuItem( + text = { Text("Diagnostics") }, + onClick = { + overflowExpanded = false + onOpenDiagnostics() + }, + modifier = Modifier.testTag(DiagnosticTags.OPEN), + ) + DropdownMenuItem( + text = { Text("Disconnect") }, + onClick = { + overflowExpanded = false + onDisconnect() + }, ) } } @@ -249,7 +389,77 @@ internal fun ConnectedSessionScreen( } @Composable -private fun RawTerminal(modifier: Modifier = Modifier) { +private fun ExpandedConnectedSessionHeader( + displayName: String, + status: String, + showingRawTerminal: Boolean, + canSwitchMode: Boolean, + onSwitchMode: () -> Unit, + onControlC: () -> Unit, + onOpenHome: () -> Unit, + onOpenDiagnostics: () -> Unit, + onDisconnect: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, top = 8.dp, bottom = 8.dp), + ) { + Text( + displayName, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.semantics { heading() }, + ) + Text(status, style = MaterialTheme.typography.labelSmall) + } + if (canSwitchMode) { + TextButton( + onClick = onSwitchMode, + modifier = Modifier + .padding(end = 8.dp) + .testTag(TranscriptTags.MODE_SWITCH), + ) { + Text(if (showingRawTerminal) "Transcript" else "Terminal") + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp) + .testTag(TranscriptTags.SESSION_ACTIONS), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (showingRawTerminal) { + TextButton(onClick = onControlC) { Text("Ctrl-C") } + } + TextButton( + onClick = onOpenHome, + modifier = Modifier.testTag(TranscriptTags.HOME), + ) { + Text("Home") + } + TextButton( + onClick = onOpenDiagnostics, + modifier = Modifier.testTag(DiagnosticTags.OPEN), + ) { + Text("Diagnostics") + } + TextButton(onClick = onDisconnect) { Text("Disconnect") } + } +} + +@Composable +private fun RawTerminal( + modifier: Modifier = Modifier, + showExtraKeys: Boolean = true, +) { val bridge = SessionRuntime.terminal val modifiers by bridge.modifiers.collectAsState() var showSoftKeyboard by remember { mutableStateOf(true) } @@ -272,7 +482,8 @@ private fun RawTerminal(modifier: Modifier = Modifier) { Box( modifier = Modifier .weight(1f) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(TranscriptTags.RAW_TERMINAL_VIEWPORT), ) { Terminal( terminalEmulator = bridge.emulator, @@ -282,15 +493,17 @@ private fun RawTerminal(modifier: Modifier = Modifier) { modifier = Modifier.fillMaxSize(), ) } - HorizontalDivider() - TerminalExtraKeyRow( - modifiers = modifiers, - onToggleControl = bridge::toggleControl, - onToggleAlt = bridge::toggleAlt, - onKey = bridge::sendKey, - onShowKeyboard = { showSoftKeyboard = false }, - modifier = Modifier.fillMaxWidth(), - ) + if (showExtraKeys) { + HorizontalDivider() + TerminalExtraKeyRow( + modifiers = modifiers, + onToggleControl = bridge::toggleControl, + onToggleAlt = bridge::toggleAlt, + onKey = bridge::sendKey, + onShowKeyboard = { showSoftKeyboard = false }, + modifier = Modifier.fillMaxWidth(), + ) + } } } diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 70f1bfe..3c08ad8 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -111,9 +111,9 @@ layout bug instead of leaving it in polish. ## Compact-height connected-session layout -**Status:** Deferred responsive-layout work; the alpha.3 session/resize behavior -is accepted, but raw-terminal visibility with a landscape software keyboard is -not a finished experience. +**Status:** Implemented in the issue #36 source with focused 200% font-scale +regressions and API 35 emulator acceptance; Galaxy owner-device recheck remains +for the next shared candidate. Permanent alpha.3 testing on the Galaxy S25 Ultra confirmed that a running command survives transcript/raw-terminal switching and rotation in both views. @@ -125,11 +125,14 @@ evidence about the constraint, not a request to reproduce its layout or style. Keep rotation support: it is normal Android behavior, useful for wide terminal lines, and more important on tablets, foldables, and hardware keyboards. During -the visual/responsive pass, evaluate a compact connected header, moving -secondary actions into overflow, and an optional focused or immersive terminal -presentation. Define a minimum visible terminal viewport when the IME is open, -while keeping connection state and a route back to the transcript reachable. -Portrait remains the primary phone layout. +compact-height raw-terminal use, the connected header now collapses to the host +identity and state plus immediate Transcript and More actions. More keeps Gboard +open and contains the terminal-key toggle, Ctrl-C, Home, Diagnostics, and an +intentionally last Disconnect action. The terminal-key row is hidden by default +so the live terminal retains readable rows; showing it is an explicit temporary +tradeoff. Gesture and three-button API 35 checks both preserve the terminal +viewport and keep More clear of the navigation bar. Portrait remains the primary +phone layout and is unchanged. ## Drafting and queued commands while a turn runs diff --git a/docs/STATUS.md b/docs/STATUS.md index 315c7b9..b143bab 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Threadline current status -Updated: 2026-08-27 +Updated: 2026-09-08 This is the canonical execution-status page. `PROJECT_SPEC.md` remains the normative product and technical specification. Dated investigations are historical evidence for the boundary they @@ -10,7 +10,7 @@ record; their old "next" or "remaining" sections do not override this page. **Phase 5 — Alpha polish is in progress.** Phases 0 through 4 are complete. -Four Phase 5 slices are implemented: +Five Phase 5 slices are implemented: - **Accessibility and error UX:** typed, non-secret errors; direct recovery and focus movement; assertive announcements; navigable headings; spoken terminal-key labels; and connected-session @@ -24,6 +24,8 @@ Four Phase 5 slices are implemented: - **Basic onboarding:** a versioned one-screen introduction, contextual connection/security guidance, a Help reopen path, blank production connection defaults, and status-bar-safe custom headers. +- **Compact raw-terminal UX:** a one-row connected header, immediately reachable Transcript and + More actions, and a keyboard-open terminal viewport across gesture and three-button navigation. Their evidence is recorded in the [accessibility and error investigation](investigations/2026-07-31-phase5-accessibility-error-ux.md), @@ -48,8 +50,9 @@ alignment, identity, and version inspection. On the Galaxy S25 Ultra, the critical physical path passed: password SSH, structured and raw views, repeated switching while `ping` remained active, and rotation in each view all preserved the live session without the alpha.2 crash. The release/JNI blocker is closed; -the landscape software-keyboard screenshot adds a deferred compact-height -layout issue because little or no terminal output remains visible. Two +the landscape software-keyboard screenshot captured a compact-height layout +issue because little or no terminal output remained visible. The current source +candidate addresses that boundary as described below. Two portable AES-256-encrypted signing-key backups are held separately from their decryption secret; independent download, extraction, key-entry, alias, and certificate checks passed for both provider copies, closing restore @@ -203,6 +206,16 @@ API 35 instrumentation dismisses and restores the real terminal IME, and the trailing half of the screen. Exact merged-main CI, permanent signing, update installation, and owner-device acceptance remain pending. +The issue #36 source keeps multiple raw-terminal rows visible with Gboard open +in landscape by collapsing the connected header and hiding the mobile-key row +until requested. Transcript stays immediate; More exposes the terminal-key +toggle, Ctrl-C, Home, Diagnostics, and Disconnect without dismissing the IME. +Focused constrained-height tests cover 200% font scale and preserve the live +terminal composition through compact-mode transitions. API 35 emulator checks +pass with gesture and three-button navigation. The three-button inset keeps More +clear of the side navigation bar. This work does not require a dedicated signed +alpha; Galaxy owner-device acceptance remains for the next shared candidate. + ## Remaining Phase 5 boundaries - Technical-alpha use sufficient to evaluate the Phase 5 exit criterion. diff --git a/docs/images/compact-terminal-landscape.png b/docs/images/compact-terminal-landscape.png new file mode 100644 index 0000000..68998b9 Binary files /dev/null and b/docs/images/compact-terminal-landscape.png differ diff --git a/docs/screenshots.md b/docs/screenshots.md index f1d5a3e..745ce34 100644 --- a/docs/screenshots.md +++ b/docs/screenshots.md @@ -1,10 +1,11 @@ # Threadline prototype screenshots These screenshots were captured from the Android prototype on an API 35 emulator -at the default font scale. The onboarding capture was refreshed on 2026-09-06; -the remaining captures are from 2026-08-01. They use the repository's local OpenSSH -fixture, its documented emulator address, and synthetic test output. No production -host, credential, command, or server fingerprint is shown. +at the default font scale. The compact landscape capture was added on 2026-09-08, +the onboarding capture was refreshed on 2026-09-06, and the remaining captures +are from 2026-08-01. They use the repository's local OpenSSH fixture, its documented +emulator address, and synthetic test output. No production host, credential, +command, or server fingerprint is shown. The screenshots document working behavior. Typography, spacing, color, and responsive layout remain prototype UI rather than a final visual design. @@ -26,6 +27,15 @@ code, output, and follow-up actions together. The terminal view remains attached to the same PTY for interactive work and exposes mobile modifier and navigation keys. +## Compact landscape terminal + +[![The raw terminal retaining readable rows above Gboard with a compact connected header](images/compact-terminal-landscape.png)](images/compact-terminal-landscape.png) + +On API 35, opening Gboard in landscape collapses the connected header while +keeping the session identity, state, Transcript, and More reachable. The +terminal-key row stays available through More and is hidden by default in this +constrained state. Gesture and three-button navigation were checked separately. + ## Local transcript history [![The saved-transcript dialog showing one synthetic local fixture session](images/prototype-history.png)](images/prototype-history.png)