diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt index 4828bd85..4f6a7307 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt @@ -92,7 +92,9 @@ suspend fun runTuiApp(host: String, port: Int) { }, ) { Panel(title = "status") { StatusBar(state) } - Panel(title = "session list") { SessionList(state.sessions.sessions, state.sessions.selectedId) } + Panel(title = "session list") { + SessionList(state.sessions.sessions, state.sessions.selectedId, state.sessions.filter) + } val activeSession = state.sessions.sessions.find { it.id == state.sessions.selectedId } Panel(title = "active session") { ActiveSession(activeSession) } if (state.approval.active != null) { diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt index 8d7d5b3e..1330d27f 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt @@ -12,12 +12,17 @@ private const val SECS_PER_HOUR = 3600L @Suppress("FunctionNaming") @Composable -fun SessionList(sessions: List, selectedId: String?) { +fun SessionList(sessions: List, selectedId: String?, filter: String = "") { Column { - if (sessions.isEmpty()) { + val filtered = if (filter.isEmpty()) { + sessions + } else { + sessions.filter { it.workflowId.contains(filter, ignoreCase = true) } + } + if (filtered.isEmpty()) { Text("(no sessions)") } else { - sessions.forEach { session -> + filtered.forEach { session -> val pointer = if (session.id == selectedId) "▶" else " " val stage = session.currentStage?.let { " stage $it" } ?: "" val ago = formatAgo(session.lastEventAt) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt index 66558962..3e5f5d52 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt @@ -4,11 +4,21 @@ import androidx.compose.runtime.Composable import com.correx.apps.tui.state.TuiState import com.jakewharton.mosaic.ui.Text +private const val MS_PER_SECOND = 1000L + @Suppress("FunctionNaming") @Composable fun StatusBar(state: TuiState) { val connectionLabel = when { - state.connection.reconnecting -> "○ reconnecting..." + state.connection.reconnecting -> { + val secondsRemaining = if (state.connection.nextRetryAtMs != null) { + val now = System.currentTimeMillis() + maxOf(0, (state.connection.nextRetryAtMs - now) / MS_PER_SECOND) + } else { + 0 + } + "○ reconnecting (attempt ${state.connection.attempt}, retry in ${secondsRemaining}s)" + } state.connection.connected -> "● connected" else -> "○ disconnected" } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt index 6574fe78..c1acd6ee 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt @@ -55,12 +55,14 @@ class TuiWsClient( suspend fun connect() { var delayMs = INITIAL_RETRY_DELAY_MS + var attempt = 0 while (true) { runCatching { client.webSocket(method = HttpMethod.Get, host = host, port = port, path = "/stream") { session = this _connection.tryEmit(ConnectionEvent.Connected) delayMs = INITIAL_RETRY_DELAY_MS + attempt = 0 for (frame in incoming) { if (frame is Frame.Text) { runCatching { @@ -74,7 +76,9 @@ class TuiWsClient( }.onFailure { println("WS connect failed: ${it.message}") } session = null _connection.tryEmit(ConnectionEvent.Disconnected) - // TODO Task 4.3: emit RetryScheduled with attempt count and nextRetryAtMs + attempt++ + val nextRetryAtMs = System.currentTimeMillis() + delayMs + _connection.tryEmit(ConnectionEvent.RetryScheduled(attempt = attempt, nextRetryAtMs = nextRetryAtMs)) delay(delayMs) delayMs = minOf(delayMs * 2, MAX_RETRY_DELAY_MS) } diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt index e29950ab..13e103f8 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt @@ -63,6 +63,16 @@ class SessionsReducerTest { assertTrue(effects.isEmpty()) } + @Test + fun `SubmitInput in Filter mode with empty text clears filter`() { + val s = SessionsState(filter = "old") + val (state, effects) = reduce( + sessions = s, inputMode = InputMode.Filter, inputText = "", action = Action.SubmitInput, + ) + assertEquals("", state.filter) + assertTrue(effects.isEmpty()) + } + @Test fun `CancelInput in Filter mode clears filter`() { val s = SessionsState(filter = "something") diff --git a/docs/plans/2026-05-17-tui-refactor.progress.md b/docs/plans/2026-05-17-tui-refactor.progress.md index 5d7d6c09..1dfff345 100644 --- a/docs/plans/2026-05-17-tui-refactor.progress.md +++ b/docs/plans/2026-05-17-tui-refactor.progress.md @@ -108,6 +108,22 @@ [next] 2.3 → 3.1 → 3.2 → 3.3 → {4.1, 4.2, 4.3} → 5.1 → 5.2 ``` +## Renderer evaluation — Mosaic is the floor + +Mosaic (jake-wharton/mosaic 0.18) is intentionally minimalist: re-emits plain text lines per frame, no screen-cell buffer, no widget catalog, no built-in borders/modals/scroll regions/mouse, no focus management. We hand-draw `─├┤` in `components/Panel.kt`. That is the entire framing system. + +This is fine for a reducer/effect prototype but it caps how polished the TUI can ever look. Compared to Python's Textual we lack: cell-buffer rendering, layout engine, widget library (DataTable / Tree / Input / Modal), focus + mouse, scroll regions, transitions. + +**Candidate replacements to evaluate (renderer swap; reducer/Action/Effect core stays):** +- **tamboui** — user-suggested: https://github.com/tamboui/tamboui (not investigated yet — no web access this session). +- **Kotter** — Compose-style DSL on JVM, more capable than Mosaic (real text-attribute model, animations, structured input). Closest semantic match to current code. +- **Lanterna** — Java TUI lib with real `TerminalScreen` buffer, GUI widgets (`Panel`, `Window`, `Table`, `TextBox`), borders, layouts, mouse. Imperative/Swing-style; bigger rewrite of `TuiApp` + `components/` but reducers untouched. +- **Mordant** — already transitive; only buys nicer ANSI paint, same ceiling as Mosaic. + +**Migration cost:** ~all of `apps/tui/src/main/.../TuiApp.kt` and `components/*`. Unchanged: `input/`, `reducer/`, `state/`, `ws/` — the renderer-agnostic core. That's the architectural payoff of Task 2.2/2.3. + +**Decision:** deferred. Capture as standalone spec before swapping. + ## Notes for the next session - Working tree currently has no commits since `58670a6`; everything is staged-or-modified. Consider committing per task on resume. - Kotlin is now 2.2.10 repo-wide; expect occasional `experimental context receivers` warnings (`-Xcontext-receivers`) — future Kotlin will error on this.