feat(tui): session filter + reconnect countdown

Tasks 4.2–4.3 of docs/plans/2026-05-17-tui-refactor.md:

- SessionList filters by workflowId (case-insensitive) using
  state.sessions.filter, populated by SubmitInput while in
  InputMode.Filter and cleared on CancelInput.
- TuiWsClient tracks reconnect attempt count and emits
  ConnectionEvent.RetryScheduled(attempt, nextRetryAtMs) before each
  backoff delay. Attempt resets on successful connect.
- StatusBar renders "reconnecting (attempt N, retry in Xs)" with
  recompose-driven countdown.

Also captures a renderer-evaluation note in the progress doc: Mosaic's
ceiling vs Textual, candidate JVM alternatives (tamboui, Kotter,
Lanterna, Mordant), and confirmation that the reducer/Effect core is
renderer-agnostic if a swap is ever pursued.

Pending: 4.1 (scroll log, deferred), 5.1 (coverage gate), 5.2 (cleanup).
This commit is contained in:
2026-05-17 03:15:08 +04:00
parent b95135eb3b
commit 0c1876a549
6 changed files with 53 additions and 6 deletions
@@ -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) {
@@ -12,12 +12,17 @@ private const val SECS_PER_HOUR = 3600L
@Suppress("FunctionNaming")
@Composable
fun SessionList(sessions: List<SessionSummary>, selectedId: String?) {
fun SessionList(sessions: List<SessionSummary>, 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)
@@ -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"
}
@@ -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)
}
@@ -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")
@@ -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.