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
4 changes: 2 additions & 2 deletions .github/workflows/goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7
with:
distribution: goreleaser
version: latest
args: release --clean
version: "~> v2"
args: release --clean --parallelism 2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
9 changes: 0 additions & 9 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,8 @@ builds:

goarch:
- amd64
- arm
- arm64

goarm:
- "6"
- "7"

goamd64:
- v2
- v3

hooks:
pre:
- cmd: go mod tidy
Expand Down
6 changes: 2 additions & 4 deletions cli/cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,8 @@ func runValidate(cmd *cobra.Command, args []string) error {
fmt.Printf("Warning: could not save report: %v\n", err)
}

if !useTUI {
fmt.Println()
fmt.Println(validate.RenderSummaryPanel(report, infra.Env, infra.Region, time.Since(runStart), terminalWidth()))
}
fmt.Println()
fmt.Println(validate.RenderSummaryPanel(report, infra.Env, infra.Region, time.Since(runStart), terminalWidth()))
fmt.Printf("\nResults saved to: %s\n", outputPath)

if !opts.noFail && report.Failed > 0 {
Expand Down
238 changes: 181 additions & 57 deletions cli/internal/scoreboard/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,37 +74,51 @@ type TUIConfig struct {
}

// RunTUI starts the interactive status board. It returns when the user
// quits (q/ctrl-c) or the context is cancelled.
// quits (q/ctrl-c) or the context is cancelled. On exit, a final static
// snapshot is printed to stdout so the last frame survives the alt-screen
// teardown (useful in tmux, where alt-screen contents aren't in scrollback).
func RunTUI(ctx context.Context, cfg TUIConfig) error {
if cfg.PollInterval <= 0 {
cfg.PollInterval = 3 * time.Second
}
m := newModel(ctx, cfg)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx))
_, err := p.Run()
final, err := p.Run()
if fm, ok := final.(*model); ok {
width := fm.width
if width <= 0 {
width = 120
}
fmt.Println(renderBoard(fm.status, fm.cfg.AnswerKey, fm.report.AgentID, fm.startTime, nil, width, 0))
}
return err
}

// RenderStatic returns the status board as a single string (used by the demo
// command to print one snapshot without entering an alt-screen TUI).
func RenderStatic(status *StatusReport, ak *AnswerKey, agentID string, startTime time.Time) string {
width := 120
return renderBoard(status, ak, agentID, startTime, nil, width)
return renderBoard(status, ak, agentID, startTime, nil, width, 0)
}

type model struct {
ctx context.Context
cfg TUIConfig
status *StatusReport
report *Report
startTime time.Time
width int
height int
lastPollAt time.Time
pollState pollResult
pollErr string
lastHash uint64
quitting bool
ctx context.Context
cfg TUIConfig
status *StatusReport
report *Report
startTime time.Time
width int
height int
lastPollAt time.Time
pollState pollResult
pollErr string
lastHash uint64
quitting bool
scrollOffset int // body-row offset for vertical scrolling
// scrollAtEnd, when true, pins the viewport to the bottom across renders
// so the user can "follow" growing content. Set by G/end, cleared by any
// upward navigation.
scrollAtEnd bool
}

func newModel(ctx context.Context, cfg TUIConfig) *model {
Expand Down Expand Up @@ -147,40 +161,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c", "esc":
m.quitting = true
return m, tea.Quit
case "r":
return m, m.pollCmd()
}
return m.handleKey(msg)
case pollMsg:
m.lastPollAt = msg.when
switch {
case msg.err == nil:
m.pollState = pollOK
m.pollErr = ""
h := simpleHash(msg.raw)
if h != m.lastHash {
m.lastHash = h
m.report = ParseReport(msg.raw)
if st, err := time.Parse(time.RFC3339, m.report.StartTime); err == nil && m.startTime.IsZero() {
m.startTime = st
}
m.status = VerifyReport(m.report, m.cfg.AnswerKey)
}
case errors.Is(msg.err, ErrNoReport):
m.pollState = pollNoFile
m.pollErr = ""
default:
m.pollState = pollError
m.pollErr = msg.err.Error()
}
// Schedule next poll
next := tea.Tick(m.cfg.PollInterval, func(time.Time) tea.Msg {
return pollKickMsg{}
})
return m, next
return m.handlePoll(msg)
case pollKickMsg:
return m, m.pollCmd()
case tickMsg:
Expand All @@ -189,6 +172,62 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c", "esc":
m.quitting = true
return m, tea.Quit
case "r":
return m, m.pollCmd()
case "j", "down":
m.scrollOffset++
m.scrollAtEnd = false
case "k", "up":
m.scrollOffset--
m.scrollAtEnd = false
case "pgdown", " ", "ctrl+d":
m.scrollOffset += m.pageSize()
m.scrollAtEnd = false
case "pgup", "ctrl+u":
m.scrollOffset -= m.pageSize()
m.scrollAtEnd = false
case "g", "home":
m.scrollOffset = 0
m.scrollAtEnd = false
case "G", "end":
m.scrollAtEnd = true
}
return m, nil
}

func (m *model) handlePoll(msg pollMsg) (tea.Model, tea.Cmd) {
m.lastPollAt = msg.when
switch {
case msg.err == nil:
m.pollState = pollOK
m.pollErr = ""
h := simpleHash(msg.raw)
if h != m.lastHash {
m.lastHash = h
m.report = ParseReport(msg.raw)
if st, err := time.Parse(time.RFC3339, m.report.StartTime); err == nil && m.startTime.IsZero() {
m.startTime = st
}
m.status = VerifyReport(m.report, m.cfg.AnswerKey)
}
case errors.Is(msg.err, ErrNoReport):
m.pollState = pollNoFile
m.pollErr = ""
default:
m.pollState = pollError
m.pollErr = msg.err.Error()
}
next := tea.Tick(m.cfg.PollInterval, func(time.Time) tea.Msg {
return pollKickMsg{}
})
return m, next
}

type pollKickMsg struct{}

func (m *model) View() string {
Expand All @@ -207,7 +246,60 @@ func (m *model) View() string {
lastPollAt: m.lastPollAt,
interval: m.cfg.PollInterval,
}
return renderBoard(m.status, m.cfg.AnswerKey, m.report.AgentID, m.startTime, pollSnap, width)
full := renderBoard(m.status, m.cfg.AnswerKey, m.report.AgentID, m.startTime, pollSnap, width, m.height)
return m.applyScroll(full)
}

// pageSize returns the body-row count for one PgUp/PgDn jump. It matches
// the scroll viewport's height (terminal height minus the pinned top
// border, totals header, and bottom border).
func (m *model) pageSize() int {
if m.height <= 4 {
return 1
}
return m.height - 3
}

// applyScroll trims `full` to the visible region when content exceeds the
// terminal height. The top border, totals-header row, and bottom border
// stay pinned; everything between scrolls based on m.scrollOffset. Offset
// clamping happens here because the natural content height isn't known
// until renderBoard has run.
func (m *model) applyScroll(full string) string {
if m.height <= 0 {
return full
}
lines := strings.Split(full, "\n")
if len(lines) <= m.height {
m.scrollOffset = 0
return full
}
const pinTop = 2 // top border + totals header row
const pinBottom = 1
if m.height <= pinTop+pinBottom+1 {
return full
}
middle := lines[pinTop : len(lines)-pinBottom]
viewport := m.height - pinTop - pinBottom
maxOffset := len(middle) - viewport
if maxOffset < 0 {
maxOffset = 0
}
if m.scrollAtEnd {
m.scrollOffset = maxOffset
}
if m.scrollOffset > maxOffset {
m.scrollOffset = maxOffset
}
if m.scrollOffset < 0 {
m.scrollOffset = 0
}
visible := middle[m.scrollOffset : m.scrollOffset+viewport]
out := make([]string, 0, pinTop+len(visible)+pinBottom)
out = append(out, lines[0], lines[1])
out = append(out, visible...)
out = append(out, lines[len(lines)-1])
return strings.Join(out, "\n")
}

type pollSnapshot struct {
Expand All @@ -219,7 +311,11 @@ type pollSnapshot struct {
interval time.Duration
}

func renderBoard(status *StatusReport, ak *AnswerKey, agentID string, startTime time.Time, poll *pollSnapshot, width int) string {
// renderBoard renders the status board at the given width. When height > 0
// and the natural layout would exceed it, the board switches to a compact
// mode that drops blank spacer rows and the keyboard hint so the essential
// content stays on-screen in short panes (e.g. small tmux splits).
func renderBoard(status *StatusReport, ak *AnswerKey, agentID string, startTime time.Time, poll *pollSnapshot, width, height int) string {
innerWidth := width - 4 // 2 chars border + 2 chars padding (1 each side)
if innerWidth < 40 {
innerWidth = 40
Expand All @@ -234,14 +330,42 @@ func renderBoard(status *StatusReport, ak *AnswerKey, agentID string, startTime
right := renderColumn(rightGroups, status, ak, colWidth)
cols := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)

parts := []string{header, "", cols}
if len(status.UnmatchedFindings) > 0 {
parts = append(parts, "",
styleFaint.Italic(true).Render(fmt.Sprintf(" + %d additional finding(s) reported", len(status.UnmatchedFindings))))
hasUnmatched := len(status.UnmatchedFindings) > 0
hasPoll := poll != nil

contentRows := 1 + lipgloss.Height(cols) // header + columns
spacerRows := 1 // after header
if hasUnmatched {
contentRows++
spacerRows++
}
if hasPoll {
contentRows += 2 // footer + hint
spacerRows++
}
natural := contentRows + spacerRows + 2 // borders
compact := height > 0 && natural > height

parts := []string{header}
if !compact {
parts = append(parts, "")
}
if poll != nil {
parts = append(parts, "", renderPollFooter(poll))
parts = append(parts, styleFaint.Render(" q/ctrl-c quit · r reload"))
parts = append(parts, cols)
if hasUnmatched {
if !compact {
parts = append(parts, "")
}
parts = append(parts, styleFaint.Italic(true).Render(fmt.Sprintf(" + %d additional finding(s) reported", len(status.UnmatchedFindings))))
}
if hasPoll {
if !compact {
parts = append(parts, "")
}
parts = append(parts, renderPollFooter(poll))
// Always show the hint when the live TUI is wired up: the scroll
// keys are critical when content overflows, and compact mode
// already saved the spacer above us.
parts = append(parts, styleFaint.Render(" q quit · r reload · j/k scroll · g/G top/bottom"))
}

return panelWithTitle("DreadGOAD STATUS BOARD", strings.Join(parts, "\n"), width)
Expand Down
28 changes: 25 additions & 3 deletions cli/internal/validate/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,31 @@ func (m *liveModel) View() string {

header := renderValidateHeader(&m.report, m.cfg.Env, m.cfg.Region, time.Since(m.startTime), innerWidth)
subhdr := styleGroupHdr.Render(fmt.Sprintf(" CHECK RESULTS (%d/%d)", m.report.Passed, m.report.Passed+m.report.Failed+m.report.Warnings))

parts := []string{header, "", subhdr, "", cols, "", m.renderFooter()}
parts = append(parts, styleFaint.Render(" q/ctrl-c quit"))
footer := m.renderFooter()

// Compact mode: drop blank spacers and the keyboard hint when the
// natural layout exceeds the terminal height (e.g. short tmux pane).
contentRows := 1 + 1 + lipgloss.Height(cols) + 1 + 1 // header, subhdr, cols, footer, hint
spacerRows := 3 // header→subhdr, subhdr→cols, cols→footer
natural := contentRows + spacerRows + 2 // borders
compact := m.height > 0 && natural > m.height

parts := []string{header}
if !compact {
parts = append(parts, "")
}
parts = append(parts, subhdr)
if !compact {
parts = append(parts, "")
}
parts = append(parts, cols)
if !compact {
parts = append(parts, "")
}
parts = append(parts, footer)
if !compact {
parts = append(parts, styleFaint.Render(" q/ctrl-c quit"))
}

return panelWithTitle("DreadGOAD VALIDATION", strings.Join(parts, "\n"), width)
}
Expand Down
Loading