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
+1
View File
@@ -48,6 +48,7 @@ func PreviewFrame(kind string, w, h int) string {
s.Events = sampleEvents() s.Events = sampleEvents()
s.Active = true s.Active = true
s.ToolsByStage = sampleManifest() s.ToolsByStage = sampleManifest()
s.LastEventAt = nowMillis() - 3000 // 3s ago — exercises the last-event clock
} }
if kind == "insert" { if kind == "insert" {
m.editMode = ModeInsert m.editMode = ModeInsert
+16
View File
@@ -322,6 +322,22 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if m.configIndex >= len(m.configFields) { if m.configIndex >= len(m.configFields) {
m.configIndex = 0 m.configIndex = 0
} }
case protocol.TypeRouterResponse, protocol.TypeProtocolError:
// Recognized but intentionally not rendered here: router.response is
// superseded by chat.turn events on the global stream, and protocol_error is
// a transport diagnostic. Explicit no-op so the default below means *unknown*.
default:
// Unknown-event raw fallback (TUI-requirements §2): a session-scoped event
// type this client doesn't recognize is surfaced as a raw row in the event
// stream, never silently dropped — the frontend must not lie by omission.
if msg.SessionID != "" {
s := m.ensureSession(msg.SessionID)
at := msg.OccurredAt
if at == 0 {
at = nowMillis()
}
s.addEvent(at, msg.Type, "(raw — unrendered event)")
}
} }
// Background-update badge for non-selected sessions. // Background-update badge for non-selected sessions.
@@ -0,0 +1,64 @@
package app
import (
"testing"
"github.com/correx/tui-go/internal/protocol"
)
func TestAgoLabel(t *testing.T) {
cases := []struct {
ms int64
want string
}{
{-100, "0s ago"},
{0, "0s ago"},
{3_000, "3s ago"},
{59_000, "59s ago"},
{90_000, "1m ago"},
{3_600_000, "1h ago"},
{90_000_000, "1d ago"},
}
for _, c := range cases {
if got := agoLabel(c.ms); got != c.want {
t.Errorf("agoLabel(%d) = %q, want %q", c.ms, got, c.want)
}
}
}
// An event type this client doesn't recognize must still surface as a raw row in the
// session's event stream — never silently dropped (TUI-requirements §2).
func TestUnknownEventSurfacesAsRawRow(t *testing.T) {
m := NewModel(nil)
m.applyServer(protocol.ServerMessage{
Type: "some.future.event",
SessionID: "s1",
OccurredAt: 1_700_000_000_000,
})
s := m.session("s1")
if s == nil {
t.Fatalf("unknown event did not vivify its session")
}
if len(s.Events) != 1 {
t.Fatalf("expected 1 raw event row, got %d", len(s.Events))
}
if s.Events[0].Type != "some.future.event" {
t.Errorf("raw row type = %q, want the unknown type verbatim", s.Events[0].Type)
}
}
// router.response is recognized-but-intentionally-not-rendered on the global stream;
// it must NOT be misclassified as an unknown event and spawn a spurious row.
func TestKnownIgnoredEventDoesNotSpawnRow(t *testing.T) {
m := NewModel(nil)
m.applyServer(protocol.ServerMessage{
Type: protocol.TypeRouterResponse,
SessionID: "s1",
Content: "superseded by chat.turn",
})
if len(m.sessions) != 0 {
t.Fatalf("router.response should not vivify a session, got %d", len(m.sessions))
}
}
+35
View File
@@ -13,6 +13,10 @@ const (
statusH = 1 statusH = 1
footerH = 1 footerH = 1
inputH = 4 // rounded box: 2 borders + 2 content lines 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). // 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) left := strings.Join(parts, sep)
var right []string 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 != "" { if g := m.gaugeText(); g != "" {
right = append(right, span(g, t.P.Faint)) right = append(right, span(g, t.P.Faint))
} }
@@ -126,6 +141,26 @@ func (m Model) gaugeText() string {
return strings.Join(parts, " ") 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{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
func (m Model) spinner() string { return spinnerFrames[m.frame%len(spinnerFrames)] } func (m Model) spinner() string { return spinnerFrames[m.frame%len(spinnerFrames)] }