diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md index 9b2db5c..b8f1405 100644 --- a/PROJECT_SPEC.md +++ b/PROJECT_SPEC.md @@ -627,6 +627,9 @@ When ready: 7. Complete the card when the end marker arrives. Only one transcript command may be active per shell session in MVP. +While that command runs, the composer remains editable as a local draft, but +Send stays disabled until the structured shell returns to ready. Drafting does +not queue or automatically execute a command. ### 10.2 Interactive input diff --git a/README.md b/README.md index 38e615b..e022a7e 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ ten real users can complete small remote tasks for two weeks and provide useful feedback. The current accepted signed build is `0.1.0-alpha.6`; `0.1.0-alpha.5` is an -immutable rejected artifact. Signed builds are shared directly with invited -testers. +immutable rejected artifact. The current source candidate is +`0.1.0-alpha.7`. Signed builds are shared directly with invited testers. There is no public signed APK or GitHub prerelease while Phase 5 is open. Testers should obtain the APK and its checksum from the owner through the agreed private channel, then follow the @@ -104,8 +104,9 @@ connection setup, and saved transcript history. state - A multiline command composer and neutral command cards with one-shot stop, delayed explicit disconnect, Older/Newer command history with draft - restoration, copy, edit, rerun, output collapsing, selectable output, - confirmed HTTP(S) links, and raw-terminal switching + restoration, local drafting while a turn runs, copy, edit, rerun, output + collapsing, selectable output, confirmed HTTP(S) links, and raw-terminal + switching - Advisory detection of alternate-screen, cursor-addressing, mouse-tracking, and bracketed-paste control sequences with an explicit same-session terminal handoff diff --git a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt index 6bcf5ee..cfa61a1 100644 --- a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt +++ b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt @@ -35,6 +35,7 @@ import dev.threadline.core.shell.ActiveCommand import dev.threadline.core.shell.CommandId import dev.threadline.core.shell.CommandSubmissionRejection import dev.threadline.core.shell.CommandSubmissionResult +import dev.threadline.core.shell.CompletedCommand import dev.threadline.core.shell.LifecyclePhase import dev.threadline.core.shell.StructuredShellState import dev.threadline.core.terminal.TerminalKey @@ -179,6 +180,65 @@ class TranscriptScreenTest { assertComposerText("printf one\nprintf two") } + @Test + fun runningCommandAllowsDraftingAcrossTerminalAndStateRestoration() { + val restorationTester = StateRestorationTester(composeRule) + val structuredShell = mutableStateOf(runningShell()) + var submitted: String? = null + restorationTester.setContent { + MaterialTheme { + ConnectedSessionScreen( + displayName = "Draft test session", + structuredShell = structuredShell.value, + transcript = CommandTranscriptState(), + onSubmit = { command -> + submitted = command + CommandSubmissionResult.Accepted(CommandId("next-command")) + }, + onControlC = {}, + onDisconnect = {}, + rawTerminal = { modifier -> + Text("Raw draft test surface", modifier = modifier) + }, + ) + } + } + + composeRule.onNodeWithTag(TranscriptTags.COMPOSER) + .assertIsEnabled() + .performTextInput("printf next") + composeRule.onNodeWithTag(TranscriptTags.SEND).assertIsNotEnabled() + + composeRule.onNodeWithTag(TranscriptTags.MODE_SWITCH).performClick() + composeRule.onNodeWithText("Raw draft test surface").assertIsDisplayed() + composeRule.onNodeWithTag(TranscriptTags.MODE_SWITCH).performClick() + assertComposerText("printf next") + + restorationTester.emulateSavedInstanceStateRestore() + assertComposerText("printf next") + composeRule.onNodeWithTag(TranscriptTags.SEND).assertIsNotEnabled() + + composeRule.runOnIdle { + structuredShell.value = StructuredShellState.Ready( + currentDirectory = "/tmp", + lastCommand = CompletedCommand( + id = CommandId("command-42"), + command = "test command", + directoryAtStart = "/tmp", + currentDirectory = "/tmp", + exitStatus = 1, + ), + ) + } + composeRule.onNodeWithTag(TranscriptTags.SEND) + .assertIsEnabled() + .performClick() + composeRule.runOnIdle { + assertEquals("printf next", submitted) + } + assertComposerText("") + } + @Test fun acceptedCardRerunResetsHistoryNavigationWithoutClearingComposer() { var submitted: String? = null diff --git a/app/src/main/java/dev/threadline/TranscriptScreen.kt b/app/src/main/java/dev/threadline/TranscriptScreen.kt index b145ad9..4ea6d41 100644 --- a/app/src/main/java/dev/threadline/TranscriptScreen.kt +++ b/app/src/main/java/dev/threadline/TranscriptScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -131,6 +132,7 @@ internal fun ConnectedSessionScreen( rawTerminal: @Composable (Modifier) -> Unit = { RawTerminal(it) }, ) { var rawModeRequested by rememberSaveable { mutableStateOf(false) } + val transcriptStateHolder = rememberSaveableStateHolder() val rawModeRequired = structuredShell is StructuredShellState.Unavailable val showingRawTerminal = rawModeRequested @@ -193,17 +195,19 @@ internal fun ConnectedSessionScreen( .fillMaxSize(), ) } else { - TranscriptSurface( - structuredShell = structuredShell, - transcript = transcript, - onSubmit = onSubmit, - onStop = onControlC, - onDisconnect = onDisconnect, - onOpenTerminal = { rawModeRequested = true }, - modifier = Modifier - .padding(contentPadding) - .fillMaxSize(), - ) + transcriptStateHolder.SaveableStateProvider("transcript") { + TranscriptSurface( + structuredShell = structuredShell, + transcript = transcript, + onSubmit = onSubmit, + onStop = onControlC, + onDisconnect = onDisconnect, + onOpenTerminal = { rawModeRequested = true }, + modifier = Modifier + .padding(contentPadding) + .fillMaxSize(), + ) + } } } } @@ -458,7 +462,8 @@ internal fun TranscriptSurface( label = { Text("Command") }, minLines = 1, maxLines = 5, - enabled = structuredShell is StructuredShellState.Ready, + enabled = structuredShell is StructuredShellState.Ready || + structuredShell is StructuredShellState.Running, modifier = Modifier .weight(1f) .testTag(TranscriptTags.COMPOSER), diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index c0427b5..de9ae23 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -92,22 +92,22 @@ Portrait remains the primary phone layout. ## Drafting and queued commands while a turn runs -**Status:** Deferred interaction design; not a Phase 5 interruption blocker. - -The transcript composer is currently disabled whenever the structured shell is -not ready. Physical testing showed that interrupt and recovery are fast, but it -also made the user wait before typing the likely follow-up command. - -Treat two possible improvements separately: - -1. Allow editing a local draft while a command is running, while keeping Send - unavailable until the shell is ready. Preserve the draft through stopping, - failure, raw-mode switching, rotation, and saved-state restoration. -2. Consider an explicit command queue only as a larger feature. It must define - ordering, visibility, reordering or removal, behavior after failure or - interruption, disconnect handling, and whether queued content is persisted. - Never send a queued command merely because the shell returned to readiness - unless the UI made that execution contract unambiguous. +**Status:** Local drafting implemented in the alpha.7 source; command queue +deferred. + +Physical testing showed that interrupt and recovery are fast, but disabling the +composer during a running turn made the user wait before typing the likely +follow-up command. The alpha.7 source keeps the composer editable while a turn +runs, preserves the local draft through stopping, failure, raw-mode switching, +rotation, and saved-state restoration, and enables Send only when the shell is +ready again. + +The implemented draft behavior remains separate from an explicit command queue. +That queue remains a larger feature and must define ordering, +visibility, reordering or removal, behavior after failure or interruption, +disconnect handling, and whether queued content is persisted. Never send a +queued command merely because the shell returned to readiness unless the UI +made that execution contract unambiguous. ## Opt-in saved password authentication diff --git a/docs/STATUS.md b/docs/STATUS.md index e13b78b..7eeebdc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Threadline current status -Updated: 2026-08-10 +Updated: 2026-08-11 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 @@ -104,6 +104,13 @@ terminal correctly remained terminal-only rather than creating transcript cards. See the [alpha.6 Ed25519 shrinker correction](investigations/2026-08-10-alpha6-ed25519-shrinker-correction.md). +Product work continues while invited alpha use is gathered. The current source +candidate is `0.1.0-alpha.7` (`10007`). Its first slice keeps the structured +composer editable while a command runs, preserves that local draft through +same-session terminal switching and Android saved-state restoration, and keeps +Send disabled until the shell returns to ready. It does not queue or +automatically execute commands. + ## Remaining Phase 5 boundaries - Technical-alpha use sufficient to evaluate the Phase 5 exit criterion. diff --git a/gradle.properties b/gradle.properties index b5af8e1..8f1497c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,5 +4,5 @@ org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 android.useAndroidX=true android.nonTransitiveRClass=true threadline.releaseApplicationId=io.github.r055le.threadline -threadline.versionCode=10006 -threadline.versionName=0.1.0-alpha.6 +threadline.versionCode=10007 +threadline.versionName=0.1.0-alpha.7