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