feat(tui): render workflow progress as dim router-feed narration lines

This commit is contained in:
2026-06-03 15:07:31 +04:00
parent 46c567c835
commit e9a87febc4
3 changed files with 85 additions and 5 deletions
@@ -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")
}
}
+34 -3
View File
@@ -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
+2 -2
View File
@@ -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