From 8b896b519a85e38dd21908adc6849cdab640e6d1 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 13 Jun 2026 10:46:06 +0400 Subject: [PATCH] =?UTF-8?q?feat(tui):=20truthful-state=20=C2=A72=20?= =?UTF-8?q?=E2=80=94=20last-event=20clock=20+=20unknown-event=20raw=20fall?= =?UTF-8?q?back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/tui-go/internal/app/demo.go | 1 + apps/tui-go/internal/app/server.go | 16 +++++ .../internal/app/truthful_state_test.go | 64 +++++++++++++++++++ apps/tui-go/internal/app/view.go | 35 ++++++++++ 4 files changed, 116 insertions(+) create mode 100644 apps/tui-go/internal/app/truthful_state_test.go diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 9bbd0cd0..2e028377 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -48,6 +48,7 @@ func PreviewFrame(kind string, w, h int) string { s.Events = sampleEvents() s.Active = true s.ToolsByStage = sampleManifest() + s.LastEventAt = nowMillis() - 3000 // 3s ago — exercises the last-event clock } if kind == "insert" { m.editMode = ModeInsert diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 28d11916..1344f097 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -322,6 +322,22 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if m.configIndex >= len(m.configFields) { 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. diff --git a/apps/tui-go/internal/app/truthful_state_test.go b/apps/tui-go/internal/app/truthful_state_test.go new file mode 100644 index 00000000..6dc90e5a --- /dev/null +++ b/apps/tui-go/internal/app/truthful_state_test.go @@ -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)) + } +} diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index b13df5ed..1f1b4e01 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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)] }