diff --git a/apps/tui-go/internal/app/narration_test.go b/apps/tui-go/internal/app/narration_test.go new file mode 100644 index 00000000..138e006f --- /dev/null +++ b/apps/tui-go/internal/app/narration_test.go @@ -0,0 +1,49 @@ +package app + +import ( + "strings" + "testing" + + "github.com/correx/tui-go/internal/protocol" +) + +func TestTemplateNarrationAppendsDimLines(t *testing.T) { + m := NewModel(nil) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + sid := "s1" + m.selectedID = sid + m.sessionEntered = true + sess := Session{ID: sid, Status: "ACTIVE"} + m.sessions = []Session{sess} + + frames := []protocol.ServerMessage{ + {Type: protocol.TypeStageStarted, SessionID: sid, StageID: "validate"}, + {Type: protocol.TypeStageCompleted, SessionID: sid, StageID: "validate"}, + {Type: protocol.TypeStageFailed, SessionID: sid, StageID: "generate", Reason: "schema mismatch"}, + {Type: protocol.TypeSessionCompleted, SessionID: sid}, + } + for _, f := range frames { + m.applyServer(f) + } + got := m.routerMessages[sid] + if len(got) != 4 { + t.Fatalf("want 4 narration entries, got %d: %+v", len(got), got) + } + for _, e := range got { + if e.Role != "narration" { + t.Fatalf("want role=narration, got %q", e.Role) + } + } + if !strings.Contains(got[0].Content, "validate") || !strings.HasPrefix(got[0].Content, "→") { + t.Fatalf("stage.started line: %q", got[0].Content) + } + if !strings.Contains(got[2].Content, "schema mismatch") { + t.Fatalf("stage.failed line should carry reason: %q", got[2].Content) + } + rows := m.routerRows(80, 20) + joined := strings.Join(rows, "\n") + if strings.Contains(joined, "awaiting router response") { + t.Fatalf("placeholder must be gone once feed has entries") + } +} diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 29567fc3..c5e2ad1b 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -47,6 +47,35 @@ func inferCategory(t string) string { } } +// narrationLine maps a progress frame to a dim router-feed line, or "" if the +// frame is not a narration trigger. +func narrationLine(msg protocol.ServerMessage) string { + switch msg.Type { + case protocol.TypeStageStarted: + return "→ " + msg.StageID + case protocol.TypeStageCompleted: + return "✓ " + msg.StageID + case protocol.TypeStageFailed: + if msg.Reason != "" { + return "⚠ " + msg.StageID + ": " + msg.Reason + } + return "⚠ " + msg.StageID + case protocol.TypeSessionPaused: + return "⏸ paused" + case protocol.TypeSessionResumed: + return "▶ resumed" + case protocol.TypeSessionCompleted: + return "✓ workflow complete" + case protocol.TypeSessionFailed: + if msg.Reason != "" { + return "✗ workflow failed: " + msg.Reason + } + return "✗ workflow failed" + default: + return "" + } +} + // applyServer mutates the model for a single (non-buffered) server message. func (m *Model) applyServer(msg protocol.ServerMessage) { debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason) @@ -55,6 +84,11 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if msg.IsEventBearing() && msg.SessionID != "" { m.ensureSession(msg.SessionID) } + // Inject dim narration lines for progress frames. Runs in addition to the + // per-type handling below (which feeds the events panel separately). + if line := narrationLine(msg); line != "" { + m.appendRouter(msg.SessionID, RouterEntry{Role: "narration", Content: line}) + } switch msg.Type { case protocol.TypeSessionAnnounced: m.onSessionAnnounced(msg) @@ -102,19 +136,16 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.Tools = nil s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID) } - m.appendRouter(msg.SessionID, RouterEntry{"stage", "⟐ stage " + msg.StageID + " started"}) case protocol.TypeStageCompleted: if s := m.session(msg.SessionID); s != nil { s.CurrentStage = "" s.addEvent(msg.OccurredAt, "StageCompleted", msg.StageID) } - m.appendRouter(msg.SessionID, RouterEntry{"stage", "⟐ stage " + msg.StageID + " completed"}) case protocol.TypeStageFailed: if s := m.session(msg.SessionID); s != nil { s.CurrentStage = "" s.addEvent(msg.OccurredAt, "StageFailed", msg.StageID) } - m.appendRouter(msg.SessionID, RouterEntry{"stage", "⟐ stage " + msg.StageID + " failed"}) case protocol.TypeInferenceStarted: if s := m.session(msg.SessionID); s != nil { s.Active = true diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 54fe7ac3..60d486dc 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -354,8 +354,8 @@ func (m Model) routerRows(w, h int) []string { } case "tool": rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim)) - case "stage": - rows = append(rows, t.span(e.Content, t.P.Faint)) + case "narration", "stage": + rows = append(rows, t.span(e.Content, t.P.Dim)) } } // keep last h rows