feat(tui): truthful-state §2 — last-event clock + unknown-event raw fallback

Implements the load-bearing TUI-requirements §2 hard requirements:

- Last-event clock: "last event Ns ago" always visible in the status bar while
  in-session, updating every frame tick. Turns warn-colored when an active session
  has gone quiet past the stale threshold — the "thinking vs hung" tell. The
  WebSocket connection indicator (●/◌/○) already covers immediate drop visibility.
- Unknown-event raw fallback: applyServer now surfaces any unrecognized,
  session-scoped event type as a raw row in the event stream instead of silently
  dropping it (the frontend must not lie by omission). Recognized-but-unrendered
  types (router.response, protocol_error) get an explicit no-op so the default
  branch means genuinely unknown.

Tests: agoLabel formatting, unknown-event surfaces-as-row, known-ignored
no-spurious-row. Preview "session" frame exercises the clock.

Deferred: the full Go↔Kotlin per-event-type render matrix (§2 last bullet) — a
larger fixture effort than the core behaviors landed here.
This commit is contained in:
2026-06-13 10:46:06 +04:00
parent 4f6bfa85f7
commit 8b896b519a
4 changed files with 116 additions and 0 deletions
+35
View File
@@ -13,6 +13,10 @@ const (
statusH = 1
footerH = 1
inputH = 4 // rounded box: 2 borders + 2 content lines
// staleEventThresholdMs: past this gap since the last event, an *active* session's
// last-event clock turns warn-colored — the "thinking vs hung" tell (§2).
staleEventThresholdMs = 30_000
)
// View renders the whole frame purely from the model (immediate-mode).
@@ -93,6 +97,17 @@ func (m Model) renderStatus() string {
left := strings.Join(parts, sep)
var right []string
// Last-event clock (§2): always visible in-session, so a silent agent is never
// mistaken for a healthy one. Updates every frame tick; turns warn-colored when an
// active session has gone quiet past the stale threshold.
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
gap := nowMillis() - s.LastEventAt
clockFg := t.P.Faint
if s.Active && gap > staleEventThresholdMs {
clockFg = t.P.Warn
}
right = append(right, span("last event "+agoLabel(gap), clockFg))
}
if g := m.gaugeText(); g != "" {
right = append(right, span(g, t.P.Faint))
}
@@ -126,6 +141,26 @@ func (m Model) gaugeText() string {
return strings.Join(parts, " ")
}
// agoLabel renders a millisecond age as a compact "Ns ago" / "Nm ago" / "Nh ago" /
// "Nd ago". Drives the last-event clock (§2) — the live signal that distinguishes a
// thinking agent from a hung one.
func agoLabel(deltaMs int64) string {
if deltaMs < 0 {
deltaMs = 0
}
s := deltaMs / 1000
switch {
case s < 60:
return itoa(int(s)) + "s ago"
case s < 3600:
return itoa(int(s/60)) + "m ago"
case s < 86400:
return itoa(int(s/3600)) + "h ago"
default:
return itoa(int(s/86400)) + "d ago"
}
}
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
func (m Model) spinner() string { return spinnerFrames[m.frame%len(spinnerFrames)] }