feat(tui): render structured artifacts as a summary, not raw JSON
Approval-card and preview JSON artifacts (the freestyle execution_plan, the analyst's analysis) rendered as a raw JSON dump. Add a JSON-aware preview: a scannable goal + numbered stage list for execution_plan, indented key/value lines for other objects, with light highlighting. Non-JSON previews fall back to the raw line view unchanged.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,19 +94,29 @@ func previewPlainLines(s string) []string {
|
|||||||
return strings.Split(strings.TrimRight(s, "\n"), "\n")
|
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
|
// 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.
|
// for a real diff, plain lines otherwise. Drives band sizing and scroll bounds.
|
||||||
func previewRowCount(s string) int {
|
func previewRowCount(s string) int {
|
||||||
if isUnifiedDiff(s) {
|
if isUnifiedDiff(s) {
|
||||||
return len(parseUnifiedDiff(s))
|
return len(parseUnifiedDiff(s))
|
||||||
}
|
}
|
||||||
return len(previewPlainLines(s))
|
return len(previewDisplayLines(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderPreviewLines renders non-diff preview text as dim plain lines (single
|
// renderPreviewLines renders non-diff preview text as dim plain lines (single
|
||||||
// column), up to maxRows starting at off, each truncated to width.
|
// column), up to maxRows starting at off, each truncated to width.
|
||||||
func (m Model) renderPreviewLines(content string, width, maxRows, off int) []string {
|
func (m Model) renderPreviewLines(content string, width, maxRows, off int) []string {
|
||||||
lines := previewPlainLines(content)
|
lines := previewDisplayLines(content)
|
||||||
if off < 0 {
|
if off < 0 {
|
||||||
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)
|
out := make([]string, 0, end-off)
|
||||||
for _, ln := range lines[off:end] {
|
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
|
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.
|
// hunkStarts reads the starting old/new line numbers from an @@ -a,b +c,d @@ marker.
|
||||||
func hunkStarts(h string) (int, int) {
|
func hunkStarts(h string) (int, int) {
|
||||||
old, newer := 1, 1
|
old, newer := 1, 1
|
||||||
|
|||||||
Reference in New Issue
Block a user