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
13 changes: 11 additions & 2 deletions internal/commands/reports.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ Results can be grouped by bucket (project) or date.`,
summary += fmt.Sprintf(" (grouped by %s)", result.GroupedBy)
}

return app.OK(result,
respOpts := []output.ResponseOption{
output.WithEntity("todo"),
output.WithDisplayData(result.Todos),
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Expand All @@ -150,7 +152,14 @@ Results can be grouped by bucket (project) or date.`,
Description: "List todos in a specific project",
},
),
)
}

// Match display grouping to API grouping
if groupBy == "date" {
respOpts = append(respOpts, output.WithGroupBy("due_on"))
}

return app.OK(result, respOpts...)
},
}

Expand Down
51 changes: 39 additions & 12 deletions internal/output/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import (

// Response is the success envelope for JSON output.
type Response struct {
OK bool `json:"ok"`
Data any `json:"data,omitempty"`
Summary string `json:"summary,omitempty"`
Notice string `json:"notice,omitempty"` // Informational message (e.g., truncation warning)
Breadcrumbs []Breadcrumb `json:"breadcrumbs,omitempty"`
Context map[string]any `json:"context,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
Entity string `json:"-"` // Schema hint for presenter (not serialized)
OK bool `json:"ok"`
Data any `json:"data,omitempty"`
Summary string `json:"summary,omitempty"`
Notice string `json:"notice,omitempty"` // Informational message (e.g., truncation warning)
Breadcrumbs []Breadcrumb `json:"breadcrumbs,omitempty"`
Context map[string]any `json:"context,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
Entity string `json:"-"` // Schema hint for presenter (not serialized)
DisplayData any `json:"-"` // Alternate data for styled/markdown rendering (not serialized)
presenterOpts []presenter.PresentOption // Display options for presenter (not serialized)
}

// Breadcrumb is a suggested follow-up action.
Expand Down Expand Up @@ -475,13 +477,34 @@ func WithEntity(name string) ResponseOption {
return func(r *Response) { r.Entity = name }
}

// WithDisplayData provides alternate data for styled/markdown rendering.
// When set, the presenter uses this instead of Data, keeping Data untouched
// for JSON serialization. Use this when the response wrapper struct should be
// preserved for machine consumption but a different shape (e.g. an unwrapped
// slice) is better for human-oriented output.
func WithDisplayData(data any) ResponseOption {
return func(r *Response) { r.DisplayData = data }
}

// WithGroupBy overrides the schema's default group_by field for task list rendering.
// For example, WithGroupBy("due_on") groups todos by due date instead of project.
func WithGroupBy(field string) ResponseOption {
return func(r *Response) {
r.presenterOpts = append(r.presenterOpts, presenter.WithGroupBy(field))
}
}

// presentStyledEntity attempts schema-aware rendering for styled output.
// Returns true if the presenter handled it, false to fall back to generic.
func (w *Writer) presentStyledEntity(resp *Response) bool {
data := NormalizeData(resp.Data)
src := resp.Data
if resp.DisplayData != nil {
src = resp.DisplayData
}
data := NormalizeData(src)
var buf strings.Builder

if !presenter.Present(&buf, data, resp.Entity, presenter.ModeStyled) {
if !presenter.Present(&buf, data, resp.Entity, presenter.ModeStyled, resp.presenterOpts...) {
return false
}

Expand Down Expand Up @@ -521,10 +544,14 @@ func (w *Writer) presentStyledEntity(resp *Response) bool {
// presentMarkdownEntity attempts schema-aware rendering for Markdown output.
// Returns true if the presenter handled it, false to fall back to generic.
func (w *Writer) presentMarkdownEntity(resp *Response) bool {
data := NormalizeData(resp.Data)
src := resp.Data
if resp.DisplayData != nil {
src = resp.DisplayData
}
data := NormalizeData(src)
var buf strings.Builder

if !presenter.Present(&buf, data, resp.Entity, presenter.ModeMarkdown) {
if !presenter.Present(&buf, data, resp.Entity, presenter.ModeMarkdown, resp.presenterOpts...) {
return false
}

Expand Down
101 changes: 101 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,107 @@ func TestFormatCellStripsANSIEscapes(t *testing.T) {
}
}

// =============================================================================
// WithDisplayData Contract Tests
// =============================================================================

func TestWithDisplayDataJSONUsesData(t *testing.T) {
// JSON format should serialize Data (the wrapper), not DisplayData
var buf bytes.Buffer
w := New(Options{Format: FormatJSON, Writer: &buf})

wrapper := map[string]any{
"person": map[string]any{"name": "Alice"},
"todos": []any{map[string]any{"content": "Fix bug"}},
}
todos := []map[string]any{
{"content": "Fix bug", "completed": false},
}

err := w.OK(wrapper,
WithEntity("todo"),
WithDisplayData(todos),
)
require.NoError(t, err)

// JSON should contain the wrapper structure
output := buf.String()
assert.Contains(t, output, `"person"`)
assert.Contains(t, output, `"todos"`)
}

func TestWithDisplayDataMarkdownUsesDisplayData(t *testing.T) {
// Markdown format should use DisplayData for presenter rendering
var buf bytes.Buffer
w := New(Options{Format: FormatMarkdown, Writer: &buf})

wrapper := map[string]any{
"person": map[string]any{"name": "Alice"},
"todos": []any{map[string]any{"content": "Fix bug"}},
}
todos := []map[string]any{
{"content": "Fix bug", "completed": false, "due_on": "", "assignees": []any{}},
}

err := w.OK(wrapper,
WithEntity("todo"),
WithDisplayData(todos),
)
require.NoError(t, err)

// Markdown should render using DisplayData (task list format from todo schema)
output := buf.String()
assert.Contains(t, output, "- [ ] Fix bug")
// Should NOT contain the wrapper's raw "person" field
assert.NotContains(t, output, `"person"`)
}

func TestWithDisplayDataStyledUsesDisplayData(t *testing.T) {
// Styled format should use DisplayData for presenter rendering
var buf bytes.Buffer
w := New(Options{Format: FormatStyled, Writer: &buf})

wrapper := map[string]any{
"person": map[string]any{"name": "Alice"},
"todos": []any{map[string]any{"content": "Fix bug"}},
}
todos := []map[string]any{
{"content": "Fix bug", "completed": false, "due_on": "", "assignees": []any{}},
}

err := w.OK(wrapper,
WithEntity("todo"),
WithDisplayData(todos),
)
require.NoError(t, err)

// Styled should render the todo content (from DisplayData), not the wrapper
output := buf.String()
assert.Contains(t, output, "Fix bug")
}

func TestWithGroupByOverridesSchemaGrouping(t *testing.T) {
var buf bytes.Buffer
w := New(Options{Format: FormatMarkdown, Writer: &buf})

todos := []map[string]any{
{"content": "Task A", "completed": false, "due_on": "2026-03-01", "assignees": []any{}},
{"content": "Task B", "completed": false, "due_on": "2026-03-15", "assignees": []any{}},
}

err := w.OK(todos,
WithEntity("todo"),
WithDisplayData(todos),
WithGroupBy("due_on"),
)
require.NoError(t, err)

output := buf.String()
// Should group by due_on instead of bucket.name
assert.Contains(t, output, "## 2026-03-01")
assert.Contains(t, output, "## 2026-03-15")
}

func TestFormatCellStripsANSIFromArrayElements(t *testing.T) {
t.Run("string elements in array", func(t *testing.T) {
input := []any{"clean", "has\x1b[2Jescapes", "also\x1b[31mcolored\x1b[0m"}
Expand Down
42 changes: 28 additions & 14 deletions internal/presenter/present.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,54 @@ const (
ModeMarkdown // Literal Markdown syntax
)

// PresentOption configures presentation behavior.
type PresentOption func(*presentOpts)

type presentOpts struct {
groupBy string // overrides schema's markdown group_by
}

// WithGroupBy overrides the schema's default group_by field for task list rendering.
func WithGroupBy(field string) PresentOption {
return func(o *presentOpts) { o.groupBy = field }
}

func buildOpts(opts []PresentOption) presentOpts {
var o presentOpts
for _, fn := range opts {
fn(&o)
}
return o
}

// Present attempts schema-aware rendering of the data.
// Returns true if a schema was found and rendering was handled.
// Returns false if no schema matched (caller should fall back to generic rendering).
func Present(w io.Writer, data any, entityHint string, mode RenderMode) bool {
func Present(w io.Writer, data any, entityHint string, mode RenderMode, opts ...PresentOption) bool {
schema := Detect(data, entityHint)
if schema == nil {
return false
}

theme := tui.ResolveTheme()
locale := DetectLocale()
return presentWith(w, data, schema, theme, locale, mode)
return presentWith(w, data, schema, theme, locale, mode, buildOpts(opts))
}

// PresentWithTheme is like Present but accepts a theme and locale directly (for testing).
func PresentWithTheme(w io.Writer, data any, entityHint string, mode RenderMode, theme tui.Theme, locale Locale) bool {
func PresentWithTheme(w io.Writer, data any, entityHint string, mode RenderMode, theme tui.Theme, locale Locale, opts ...PresentOption) bool {
schema := Detect(data, entityHint)
if schema == nil {
return false
}

return presentWith(w, data, schema, theme, locale, mode)
return presentWith(w, data, schema, theme, locale, mode, buildOpts(opts))
}

func presentWith(w io.Writer, data any, schema *EntitySchema, theme tui.Theme, locale Locale, mode RenderMode) bool {
func presentWith(w io.Writer, data any, schema *EntitySchema, theme tui.Theme, locale Locale, mode RenderMode, opts presentOpts) bool {
switch mode {
case ModeMarkdown:
return presentMarkdown(w, data, schema, locale)
return presentMarkdown(w, data, schema, locale, opts)
default:
return presentStyled(w, data, schema, theme, locale)
}
Expand All @@ -57,9 +77,6 @@ func presentStyled(w io.Writer, data any, schema *EntitySchema, theme tui.Theme,
}
return true
case []map[string]any:
if len(d) == 0 {
return false
}
if err := RenderList(w, schema, d, styles, locale); err != nil {
return false
}
Expand All @@ -68,18 +85,15 @@ func presentStyled(w io.Writer, data any, schema *EntitySchema, theme tui.Theme,
return false
}

func presentMarkdown(w io.Writer, data any, schema *EntitySchema, locale Locale) bool {
func presentMarkdown(w io.Writer, data any, schema *EntitySchema, locale Locale, opts presentOpts) bool {
switch d := data.(type) {
case map[string]any:
if err := RenderDetailMarkdown(w, schema, d, locale); err != nil {
return false
}
return true
case []map[string]any:
if len(d) == 0 {
return false
}
if err := RenderListMarkdown(w, schema, d, locale); err != nil {
if err := RenderListMarkdown(w, schema, d, locale, opts.groupBy); err != nil {
return false
}
return true
Expand Down
Loading
Loading