Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
353 changes: 353 additions & 0 deletions cmd/roborev/tui.go

Large diffs are not rendered by default.

111 changes: 111 additions & 0 deletions cmd/roborev/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6742,3 +6742,114 @@ func TestSanitizeForDisplay(t *testing.T) {
})
}
}

func TestTUITailOutputPreservesLinesOnEmptyResponse(t *testing.T) {
// Test that when a job completes and the server returns empty lines
// (because the buffer was closed), the TUI preserves the existing lines.
m := newTuiModel("http://localhost")
m.currentView = tuiViewTail
m.tailJobID = 1
m.tailStreaming = true
m.height = 30

// Set up initial lines as if we had been streaming output
m.tailLines = []tailLine{
{timestamp: time.Now(), text: "Line 1", lineType: "text"},
{timestamp: time.Now(), text: "Line 2", lineType: "text"},
{timestamp: time.Now(), text: "Line 3", lineType: "text"},
}

// Simulate job completion: server returns empty lines, hasMore=false
emptyMsg := tuiTailOutputMsg{
lines: []tailLine{},
hasMore: false,
err: nil,
}

updated, _ := m.Update(emptyMsg)
m2 := updated.(tuiModel)

// Lines should be preserved (not cleared)
if len(m2.tailLines) != 3 {
t.Fatalf("Expected 3 lines preserved, got %d", len(m2.tailLines))
}

// Streaming should stop
if m2.tailStreaming {
t.Error("Expected tailStreaming to be false after job completes")
}

// Verify the original content is still there
if m2.tailLines[0].text != "Line 1" {
t.Errorf("Expected 'Line 1', got %q", m2.tailLines[0].text)
}
}

func TestTUITailOutputUpdatesLinesWhenStreaming(t *testing.T) {
// Test that when streaming and new lines arrive, they are updated
m := newTuiModel("http://localhost")
m.currentView = tuiViewTail
m.tailJobID = 1
m.tailStreaming = true
m.height = 30

// Set up initial lines
m.tailLines = []tailLine{
{timestamp: time.Now(), text: "Old line", lineType: "text"},
}

// New lines arrive while still streaming
newMsg := tuiTailOutputMsg{
lines: []tailLine{
{timestamp: time.Now(), text: "Old line", lineType: "text"},
{timestamp: time.Now(), text: "New line", lineType: "text"},
},
hasMore: true, // Still streaming
err: nil,
}

updated, _ := m.Update(newMsg)
m2 := updated.(tuiModel)

// Lines should be updated
if len(m2.tailLines) != 2 {
t.Errorf("Expected 2 lines, got %d", len(m2.tailLines))
}

// Streaming should continue
if !m2.tailStreaming {
t.Error("Expected tailStreaming to be true while job is running")
}
}

func TestTUITailOutputIgnoredWhenNotInTailView(t *testing.T) {
// Test that tail output messages are ignored when not in tail view
m := newTuiModel("http://localhost")
m.currentView = tuiViewQueue // Not in tail view
m.tailJobID = 1

// Existing lines from a previous tail session
m.tailLines = []tailLine{
{timestamp: time.Now(), text: "Previous session line", lineType: "text"},
}

// New lines arrive (stale message from previous tail)
msg := tuiTailOutputMsg{
lines: []tailLine{
{timestamp: time.Now(), text: "Should be ignored", lineType: "text"},
},
hasMore: false,
err: nil,
}

updated, _ := m.Update(msg)
m2 := updated.(tuiModel)

// Lines should not be updated since we're not in tail view
if len(m2.tailLines) != 1 {
t.Fatalf("Expected 1 line (unchanged), got %d", len(m2.tailLines))
}
if m2.tailLines[0].text != "Previous session line" {
t.Errorf("Lines should not be updated when not in tail view")
}
}
227 changes: 227 additions & 0 deletions internal/daemon/normalize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package daemon

import (
"encoding/json"
"regexp"
"strings"
)

// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]|\x1b\]([^\x07\x1b]|\x1b[^\\])*(\x07|\x1b\\)`)

// GetNormalizer returns the appropriate normalizer for an agent.
func GetNormalizer(agentName string) OutputNormalizer {
switch agentName {
case "claude-code":
return NormalizeClaudeOutput
case "opencode":
return NormalizeOpenCodeOutput
default:
return NormalizeGenericOutput
}
}

// claudeNoisePatterns are status messages from Claude CLI that aren't useful progress info
var claudeNoisePatterns = []string{
"mcp startup:",
"Initializing",
"Connected to",
"Session started",
}

// isClaudeNoise returns true if the line is a Claude CLI status message to filter out
func isClaudeNoise(line string) bool {
for _, pattern := range claudeNoisePatterns {
if strings.Contains(line, pattern) {
return true
}
}
return false
}

// NormalizeClaudeOutput parses Claude's stream-json format and extracts readable content.
func NormalizeClaudeOutput(line string) *OutputLine {
line = strings.TrimSpace(line)
if line == "" {
return nil
}

// Filter out Claude CLI noise/status messages
if isClaudeNoise(line) {
return nil
}

// Try to parse as JSON
var msg struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
Message struct {
Content string `json:"content,omitempty"`
} `json:"message,omitempty"`
Result string `json:"result,omitempty"`
SessionID string `json:"session_id,omitempty"`

// Tool-related fields
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`

// Content delta for streaming
ContentBlockDelta struct {
Delta struct {
Text string `json:"text,omitempty"`
} `json:"delta,omitempty"`
} `json:"content_block_delta,omitempty"`
}

if err := json.Unmarshal([]byte(line), &msg); err != nil {
// Not JSON - return as raw text (shouldn't happen with Claude)
return &OutputLine{Text: stripANSI(line), Type: "text"}
}

switch msg.Type {
case "assistant":
// Full assistant message with content
if msg.Message.Content != "" {
// Replace embedded newlines with spaces to keep output on single line
text := strings.ReplaceAll(msg.Message.Content, "\n", " ")
text = strings.ReplaceAll(text, "\r", "")
return &OutputLine{Text: text, Type: "text"}
}
// Skip empty assistant messages (e.g., start of response)
return nil

case "result":
// Final result summary
if msg.Result != "" {
// Replace embedded newlines with spaces
text := strings.ReplaceAll(msg.Result, "\n", " ")
text = strings.ReplaceAll(text, "\r", "")
return &OutputLine{Text: text, Type: "text"}
}
return nil

case "tool_use":
// Tool being called
if msg.Name != "" {
return &OutputLine{Text: "[Tool: " + msg.Name + "]", Type: "tool"}
}
return nil

case "tool_result":
// Tool finished - could show brief indicator
return &OutputLine{Text: "[Tool completed]", Type: "tool"}

case "content_block_start":
// Start of a content block - skip
return nil

case "content_block_delta":
// Streaming text delta
if msg.ContentBlockDelta.Delta.Text != "" {
// Replace embedded newlines with spaces
text := strings.ReplaceAll(msg.ContentBlockDelta.Delta.Text, "\n", " ")
text = strings.ReplaceAll(text, "\r", "")
if text == "" || text == " " {
return nil
}
return &OutputLine{Text: text, Type: "text"}
}
return nil

case "content_block_stop":
// End of content block - skip
return nil

case "message_start", "message_delta", "message_stop":
// Message lifecycle events - skip
return nil

case "system":
// System messages (e.g., init)
if msg.Subtype == "init" {
if msg.SessionID != "" {
sessionPrefix := msg.SessionID
if len(sessionPrefix) > 8 {
sessionPrefix = sessionPrefix[:8]
}
return &OutputLine{Text: "[Session: " + sessionPrefix + "...]", Type: "text"}
}
}
return nil

case "error":
// Error message
return &OutputLine{Text: "[Error in stream]", Type: "error"}

default:
// Unknown type - skip to avoid noise
return nil
}
}

// NormalizeOpenCodeOutput normalizes OpenCode output (plain text with ANSI codes).
func NormalizeOpenCodeOutput(line string) *OutputLine {
line = strings.TrimSpace(line)
if line == "" {
return nil
}

// Filter tool call JSON lines (same logic as in agent package)
if isToolCallJSON(line) {
return &OutputLine{Text: "[Tool call]", Type: "tool"}
}

// Strip ANSI codes for clean display
text := stripANSI(line)
if text == "" {
return nil
}

return &OutputLine{Text: text, Type: "text"}
}

// NormalizeGenericOutput is the default normalizer for other agents.
func NormalizeGenericOutput(line string) *OutputLine {
line = strings.TrimSpace(line)
if line == "" {
return nil
}

// Strip ANSI codes
text := stripANSI(line)
if text == "" {
return nil
}

// Filter tool call JSON if present
if isToolCallJSON(text) {
return &OutputLine{Text: "[Tool call]", Type: "tool"}
}

return &OutputLine{Text: text, Type: "text"}
}

// stripANSI removes ANSI escape sequences from a string.
func stripANSI(s string) string {
return ansiEscapePattern.ReplaceAllString(s, "")
}

// isToolCallJSON checks if a line is a tool call JSON object.
// Tool calls have exactly "name" and "arguments" keys.
func isToolCallJSON(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "{") {
return false
}
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &m); err != nil {
return false
}
// Tool calls have exactly "name" and "arguments" keys
if len(m) != 2 {
return false
}
_, hasName := m["name"]
_, hasArgs := m["arguments"]
return hasName && hasArgs
}
Loading
Loading