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:
@@ -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)] }
|
||||
|
||||
Reference in New Issue
Block a user