fetch_ml/cmd/tui/internal/view/narrative_view.go
Jeremie Fraeys 6028779239
feat: update CLI, TUI, and security documentation
- Add safety checks to Zig build
- Add TUI with job management and narrative views
- Add WebSocket support and export services
- Add smart configuration defaults
- Update API routes with security headers
- Update SECURITY.md with comprehensive policy
- Add Makefile security scanning targets
2026-02-19 15:35:05 -05:00

147 lines
3.3 KiB
Go

// Package view provides TUI rendering functionality
package view
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/jfraeys/fetch_ml/cmd/tui/internal/model"
)
// NarrativeView displays job research context and outcome
type NarrativeView struct {
Width int
Height int
}
// View renders the narrative/outcome for a job
func (v *NarrativeView) View(job model.Job) string {
if job.Hypothesis == "" && job.Context == "" && job.Intent == "" {
return v.renderEmpty()
}
var sections []string
// Title
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ff9e64")).
MarginBottom(1)
sections = append(sections, titleStyle.Render("📊 Research Context"))
// Hypothesis
if job.Hypothesis != "" {
sections = append(sections, v.renderSection("🧪 Hypothesis", job.Hypothesis))
}
// Context
if job.Context != "" {
sections = append(sections, v.renderSection("📚 Context", job.Context))
}
// Intent
if job.Intent != "" {
sections = append(sections, v.renderSection("🎯 Intent", job.Intent))
}
// Expected Outcome
if job.ExpectedOutcome != "" {
sections = append(sections, v.renderSection("📈 Expected Outcome", job.ExpectedOutcome))
}
// Actual Outcome (if available)
if job.ActualOutcome != "" {
statusIcon := "❓"
switch job.OutcomeStatus {
case "validated":
statusIcon = "✅"
case "invalidated":
statusIcon = "❌"
case "inconclusive":
statusIcon = "⚖️"
case "partial":
statusIcon = "📝"
}
sections = append(sections, v.renderSection(statusIcon+" Actual Outcome", job.ActualOutcome))
}
// Combine all sections
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Apply border and padding
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7aa2f7")).
Padding(1, 2).
Width(v.Width - 4)
return style.Render(content)
}
func (v *NarrativeView) renderSection(title, content string) string {
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7aa2f7"))
contentStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#d8dee9")).
MarginLeft(2)
// Wrap long content
wrapped := wrapText(content, v.Width-12)
return lipgloss.JoinVertical(lipgloss.Left,
titleStyle.Render(title),
contentStyle.Render(wrapped),
"",
)
}
func (v *NarrativeView) renderEmpty() string {
style := lipgloss.NewStyle().
Foreground(lipgloss.Color("#88c0d0")).
Italic(true).
Padding(2)
return style.Render("No narrative data available for this job.\n\n" +
"Use --hypothesis, --context, --intent flags when queuing jobs.")
}
// wrapText wraps text to maxWidth, preserving newlines
func wrapText(text string, maxWidth int) string {
if maxWidth <= 0 {
return text
}
lines := strings.Split(text, "\n")
var result []string
for _, line := range lines {
if len(line) <= maxWidth {
result = append(result, line)
continue
}
// Simple word wrap
words := strings.Fields(line)
var currentLine string
for _, word := range words {
if len(currentLine)+len(word)+1 > maxWidth {
if currentLine != "" {
result = append(result, currentLine)
}
currentLine = word
} else {
if currentLine != "" {
currentLine += " "
}
currentLine += word
}
}
if currentLine != "" {
result = append(result, currentLine)
}
}
return strings.Join(result, "\n")
}