diff --git a/apps/tui-go/internal/app/artifact_preview.go b/apps/tui-go/internal/app/artifact_preview.go new file mode 100644 index 00000000..85190dd1 --- /dev/null +++ b/apps/tui-go/internal/app/artifact_preview.go @@ -0,0 +1,143 @@ +package app + +import ( + "encoding/json" + "sort" + "strings" +) + +// artifactDisplayLines turns a structured (JSON) artifact preview into scannable plain-text lines, +// so the approval card and transcript show a readable summary instead of a raw JSON dump. Returns +// (nil, false) for non-JSON content, which falls back to the raw line view. The lines are plain; +// colorizePreviewLine adds the highlighting at render time. Kept in sync with previewRowCount via +// previewDisplayLines so band sizing/scroll bounds match what's drawn. +func artifactDisplayLines(content string) ([]string, bool) { + trimmed := strings.TrimSpace(content) + if !strings.HasPrefix(trimmed, "{") { + return nil, false + } + var obj map[string]json.RawMessage + if json.Unmarshal([]byte(trimmed), &obj) != nil { + return nil, false + } + if lines, ok := planSummaryLines(obj); ok { + return lines, true + } + return objectSummaryLines(obj), true +} + +// planSummaryLines renders an execution_plan artifact ({goal, stages:[{id,role,prompt,produces,…}]}) +// as a goal line plus a numbered, aligned stage list — the freestyle plan approval an operator sees. +func planSummaryLines(obj map[string]json.RawMessage) ([]string, bool) { + raw, ok := obj["stages"] + if !ok { + return nil, false + } + var stages []struct { + ID string `json:"id"` + Role string `json:"role"` + Prompt string `json:"prompt"` + Produces string `json:"produces"` + } + if json.Unmarshal(raw, &stages) != nil || len(stages) == 0 { + return nil, false + } + + var out []string + if g := jsonString(obj["goal"]); g != "" { + out = append(out, "Goal", " "+g, "") + } + out = append(out, "Plan · "+plural(len(stages), "stage")) + idW := 0 + for _, s := range stages { + if len(s.ID) > idW { + idW = len(s.ID) + } + } + for i, s := range stages { + head := " " + itoa(i+1) + ". " + padRaw(s.ID, idW) + " " + padRaw(s.Role, 12) + if s.Produces != "" { + head += " → " + s.Produces + } + out = append(out, head) + if p := firstLine(s.Prompt); p != "" { + out = append(out, " "+p) + } + } + return out, true +} + +// objectSummaryLines pretty-prints any other JSON object as indented key/value lines — a readable +// fallback (e.g. the analyst's `analysis` artifact) instead of a single minified blob. +func objectSummaryLines(obj map[string]json.RawMessage) []string { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + var out []string + for _, k := range keys { + out = append(out, jsonFieldLines(k, obj[k], 0)...) + } + return out +} + +// jsonFieldLines renders one "key: value" field, recursing into nested objects/arrays with +// indentation. Scalars go inline; containers get a header line then their children. +func jsonFieldLines(key string, raw json.RawMessage, depth int) []string { + indent := strings.Repeat(" ", depth) + v := strings.TrimSpace(string(raw)) + switch { + case strings.HasPrefix(v, "{"): + var nested map[string]json.RawMessage + if json.Unmarshal(raw, &nested) == nil && len(nested) > 0 { + out := []string{indent + key + ":"} + nkeys := make([]string, 0, len(nested)) + for k := range nested { + nkeys = append(nkeys, k) + } + sort.Strings(nkeys) + for _, nk := range nkeys { + out = append(out, jsonFieldLines(nk, nested[nk], depth+1)...) + } + return out + } + case strings.HasPrefix(v, "["): + var items []json.RawMessage + if json.Unmarshal(raw, &items) == nil { + out := []string{indent + key + ": (" + itoa(len(items)) + ")"} + for _, it := range items { + out = append(out, indent+" • "+scalarString(it)) + } + return out + } + } + return []string{indent + key + ": " + scalarString(raw)} +} + +// scalarString renders a JSON scalar (or compact fallback) as a single-line human string. +func scalarString(raw json.RawMessage) string { + if s := jsonString(raw); s != "" { + return firstLine(s) + } + return firstLine(strings.TrimSpace(string(raw))) +} + +func jsonString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + return "" +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return strings.TrimSpace(s[:i]) + " …" + } + return s +} diff --git a/apps/tui-go/internal/app/artifact_preview_test.go b/apps/tui-go/internal/app/artifact_preview_test.go new file mode 100644 index 00000000..bc032c0b --- /dev/null +++ b/apps/tui-go/internal/app/artifact_preview_test.go @@ -0,0 +1,42 @@ +package app + +import "strings" + +import "testing" + +func TestArtifactDisplayLines_ExecutionPlan(t *testing.T) { + plan := `{"goal":"Scaffold a UI","stages":[ + {"id":"explore","role":"implementer","prompt":"Explore the project.\nMore detail.","produces":"exploration_report"}, + {"id":"design_ui","role":"architect","prompt":"Design it.","produces":"ui_design_doc"}]}` + lines, ok := artifactDisplayLines(plan) + if !ok { + t.Fatal("expected plan to be recognized as a structured artifact") + } + joined := strings.Join(lines, "\n") + for _, want := range []string{"Goal", "Scaffold a UI", "Plan · 2 stages", "explore", "implementer", "→ exploration_report", "design_ui"} { + if !strings.Contains(joined, want) { + t.Errorf("summary missing %q in:\n%s", want, joined) + } + } + // Multi-line prompts collapse to a single first-line summary, never a raw dump. + if strings.Contains(joined, "More detail.") { + t.Errorf("prompt body leaked into summary:\n%s", joined) + } +} + +func TestArtifactDisplayLines_NonJSONFallsBack(t *testing.T) { + if _, ok := artifactDisplayLines("just some text"); ok { + t.Error("plain text must not be treated as a structured artifact") + } +} + +func TestArtifactDisplayLines_GenericObject(t *testing.T) { + lines, ok := artifactDisplayLines(`{"summary":"did the thing","risks":["a","b"]}`) + if !ok { + t.Fatal("expected generic JSON object to summarize") + } + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "summary:") || !strings.Contains(joined, "risks: (2)") { + t.Errorf("generic object summary wrong:\n%s", joined) + } +} diff --git a/apps/tui-go/internal/app/diff.go b/apps/tui-go/internal/app/diff.go index 906a5460..5f87b9ea 100644 --- a/apps/tui-go/internal/app/diff.go +++ b/apps/tui-go/internal/app/diff.go @@ -94,19 +94,29 @@ func previewPlainLines(s string) []string { return strings.Split(strings.TrimRight(s, "\n"), "\n") } +// previewDisplayLines is the single source of truth for how a non-diff preview is laid out: +// a scannable summary for structured (JSON) artifacts, raw lines otherwise. Both the renderer +// and previewRowCount go through it so band sizing and scroll bounds match what's drawn. +func previewDisplayLines(s string) []string { + if lines, ok := artifactDisplayLines(s); ok { + return lines + } + return previewPlainLines(s) +} + // previewRowCount is the number of content rows a preview occupies — diff rows // for a real diff, plain lines otherwise. Drives band sizing and scroll bounds. func previewRowCount(s string) int { if isUnifiedDiff(s) { return len(parseUnifiedDiff(s)) } - return len(previewPlainLines(s)) + return len(previewDisplayLines(s)) } // renderPreviewLines renders non-diff preview text as dim plain lines (single // column), up to maxRows starting at off, each truncated to width. func (m Model) renderPreviewLines(content string, width, maxRows, off int) []string { - lines := previewPlainLines(content) + lines := previewDisplayLines(content) if off < 0 { off = 0 } @@ -119,11 +129,34 @@ func (m Model) renderPreviewLines(content string, width, maxRows, off int) []str } out := make([]string, 0, end-off) for _, ln := range lines[off:end] { - out = append(out, m.theme.span(truncate(ln, width), m.theme.P.Dim)) + out = append(out, m.colorizePreviewLine(ln, width)) } return out } +// colorizePreviewLine highlights a summarized-artifact line: flush-left section headers (Goal, +// Plan · N) in accent, the "→ produces" target picked out, everything else dim — so the structure +// reads at a glance without a full JSON syntax highlighter. Plain (raw) previews stay uniformly dim. +func (m Model) colorizePreviewLine(ln string, w int) string { + t := m.theme + ln = truncate(ln, w) + if ln == "" { + return "" + } + // Section header: no leading indent (e.g. "Goal", "Plan · 4 stages"). + if ln[0] != ' ' { + return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Bold(true).Render(ln) + } + // Stage head: pick out the "→ produces" target after truncation so widths stay bounded. + if i := strings.Index(ln, " → "); i >= 0 { + head := t.span(ln[:i], t.P.FgStrong) + arrow := t.span(" → ", t.P.Faint) + prod := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render(ln[i+len(" → "):]) + return head + arrow + prod + } + return t.span(ln, t.P.Dim) +} + // hunkStarts reads the starting old/new line numbers from an @@ -a,b +c,d @@ marker. func hunkStarts(h string) (int, int) { old, newer := 1, 1