diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 00000000..4f80ee97 --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,157 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "bufio" + "fmt" + "os" +) + +// Key represents a normalised keyboard event. +type Key int + +const ( + KeyUnknown Key = iota + KeyTab + KeyUp + KeyDown + KeyLeft + KeyRight + KeyEnter + KeyQuit + KeySlash + KeyEscape + KeyDiff +) + +func (k Key) String() string { + switch k { + case KeyTab: + return "Tab" + case KeyUp: + return "↑/k" + case KeyDown: + return "↓/j" + case KeyLeft: + return "←/h" + case KeyRight: + return "→/l" + case KeyEnter: + return "Enter" + case KeyQuit: + return "q" + case KeySlash: + return "/" + case KeyEscape: + return "Esc" + default: + return "?" + } +} + +func KeyHelp() string { + return "Tab:switch-pane ↑↓:navigate Enter:expand q:quit /:search" +} + +type KeyReader struct { + r *bufio.Reader +} + +func NewKeyReader() *KeyReader { + return &KeyReader{r: bufio.NewReader(os.Stdin)} +} + +func (kr *KeyReader) Read() (Key, error) { + b, err := kr.r.ReadByte() + if err != nil { + return KeyUnknown, err + } + + switch b { + case '\t': + return KeyTab, nil + case '\r', '\n': + return KeyEnter, nil + case 'q', 'Q': + return KeyQuit, nil + case 'd', 'D': + return KeyDiff, nil + case 'k': + return KeyUp, nil + case 'j': + return KeyDown, nil + case 'h': + return KeyLeft, nil + case 'l': + return KeyRight, nil + case '/': + return KeySlash, nil + case 0x1b: + return kr.readEscape() + case 0x03: + return KeyQuit, nil + } + return KeyUnknown, nil +} + +func (kr *KeyReader) readEscape() (Key, error) { + next, err := kr.r.ReadByte() + if err != nil { + return KeyEscape, nil + } + if next != '[' { + return KeyEscape, nil + } + + var seq []byte + for { + c, err := kr.r.ReadByte() + if err != nil { + break + } + seq = append(seq, c) + if c >= 0x40 && c <= 0x7E { + break + } + } + + if len(seq) == 0 { + return KeyUnknown, nil + } + + switch seq[len(seq)-1] { + case 'A': // ESC[A + return KeyUp, nil + case 'B': // ESC[B + return KeyDown, nil + case 'C': // ESC[C + return KeyRight, nil + case 'D': // ESC[D + return KeyLeft, nil + } + + return KeyUnknown, nil +} + +// TermSize returns the current terminal dimensions. It reads $COLUMNS and +// $LINES first, falling back to 80×24 when neither is set. The split-screen +// layout calls this on every resize signal to reflow the panes. +func TermSize() (width, height int) { + width = readEnvInt("COLUMNS", 80) + height = readEnvInt("LINES", 24) + return width, height +} + +func readEnvInt(name string, fallback int) int { + val := os.Getenv(name) + if val == "" { + return fallback + } + var n int + if _, err := fmt.Sscanf(val, "%d", &n); err == nil && n > 0 { + return n + } + return fallback +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 00000000..4f180049 --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,248 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "fmt" + "os" + "os/signal" + "strings" + "syscall" +) + +// Pane identifies which half of the split screen currently has keyboard focus. +type Pane int + +const ( + PaneTrace Pane = iota + PaneState + PaneDiff +) + +func (p Pane) String() string { + switch p { + case PaneTrace: + return "Trace" + case PaneState: + return "State" + case PaneDiff: + return "Diff" + default: + return "?" + } +} + +type SplitLayout struct { + Width int + Height int + Focus Pane + + LeftTitle string + MiddleTitle string + RightTitle string + + SplitRatio float64 + + ShowDiff bool + + resizeCh chan struct{} +} + +// NewSplitLayout creates a SplitLayout sized to the current terminal. +func NewSplitLayout() *SplitLayout { + w, h := TermSize() + return &SplitLayout{ + Width: w, + Height: h, + Focus: PaneTrace, + LeftTitle: "Trace", + MiddleTitle: "State", + RightTitle: "Diff", + SplitRatio: 0.4, + resizeCh: make(chan struct{}, 1), + } +} + +func (l *SplitLayout) ToggleFocus() Pane { + switch l.Focus { + case PaneTrace: + l.Focus = PaneState + case PaneState: + if l.ShowDiff { + l.Focus = PaneDiff + } else { + l.Focus = PaneTrace + } + default: // PaneDiff + l.Focus = PaneTrace + } + return l.Focus +} + +func (l *SplitLayout) SetFocus(p Pane) { + l.Focus = p +} + +func (l *SplitLayout) ToggleDiff() bool { + l.ShowDiff = !l.ShowDiff + if !l.ShowDiff && l.Focus == PaneDiff { + l.Focus = PaneState + } + return l.ShowDiff +} + +// LeftWidth returns the number of columns for the trace (leftmost) pane. +func (l *SplitLayout) LeftWidth() int { + ratio := l.SplitRatio + if ratio <= 0 || ratio >= 1 { + ratio = 0.4 + } + w := int(float64(l.Width) * ratio) + if w < 10 { + w = 10 + } + return w +} + +func (l *SplitLayout) MiddleWidth() int { + remaining := l.Width - l.LeftWidth() - 1 // –1 for left│middle divider + if !l.ShowDiff { + return remaining + } + w := remaining / 2 + if w < 8 { + w = 8 + } + return w +} + +// RightWidth returns the number of columns for the diff pane. +// Returns 0 when ShowDiff is false. +func (l *SplitLayout) RightWidth() int { + if !l.ShowDiff { + return 0 + } + remaining := l.Width - l.LeftWidth() - 1 + rw := remaining - l.MiddleWidth() - 1 // –1 for middle│right divider + if rw < 0 { + rw = 0 + } + return rw +} + +func (l *SplitLayout) ListenResize() <-chan struct{} { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGWINCH) + + go func() { + for range sig { + w, h := TermSize() + l.Width = w + l.Height = h + // Non-blocking send — skip if the consumer hasn't processed the + // previous event yet. + select { + case l.resizeCh <- struct{}{}: + default: + } + } + }() + + return l.resizeCh +} + +func (l *SplitLayout) Render(leftLines, middleLines, rightLines []string) { + lw := l.LeftWidth() + mw := l.MiddleWidth() + rw := l.RightWidth() + + contentRows := l.Height - 3 + if contentRows < 1 { + contentRows = 1 + } + + sb := &strings.Builder{} + + // ── Top border ──────────────────────────────────────────────────────────── + sb.WriteString(l.borderRow(lw, mw, rw)) + sb.WriteByte('\n') + + // ── Content rows ───────────────────────────────────────────────────────── + for row := 0; row < contentRows; row++ { + sb.WriteString(cellAt(leftLines, row, lw)) + sb.WriteString("│") + sb.WriteString(cellAt(middleLines, row, mw)) + if l.ShowDiff && rw > 0 { + sb.WriteString("│") + sb.WriteString(cellAt(rightLines, row, rw)) + } + sb.WriteByte('\n') + } + + // ── Bottom border ──────────────────────────────────────────────────────── + bottom := "+" + strings.Repeat("─", lw) + "+" + strings.Repeat("─", mw) + "+" + if l.ShowDiff && rw > 0 { + bottom += strings.Repeat("─", rw) + "+" + } + sb.WriteString(bottom) + sb.WriteByte('\n') + + // ── Status bar ─────────────────────────────────────────────────────────── + help := KeyHelp() + if l.ShowDiff { + help += " d:hide-diff" + } else { + help += " d:show-diff" + } + status := fmt.Sprintf(" [focus: %s] %s", l.Focus, help) + if len(status) > l.Width { + status = status[:l.Width] + } + sb.WriteString(status) + + fmt.Print(sb.String()) +} + +// borderRow builds the top border with centred pane titles. +func (l *SplitLayout) borderRow(lw, mw, rw int) string { + left := l.fmtTitle(l.LeftTitle, l.Focus == PaneTrace, lw) + middle := l.fmtTitle(l.MiddleTitle, l.Focus == PaneState, mw) + top := "+" + left + "+" + middle + "+" + if l.ShowDiff && rw > 0 { + right := l.fmtTitle(l.RightTitle, l.Focus == PaneDiff, rw) + top += right + "+" + } + return top +} + +func (l *SplitLayout) fmtTitle(title string, focused bool, width int) string { + marker := "" + if focused { + marker = "*" // simple ASCII focus marker visible in all terminals + } + label := fmt.Sprintf(" %s%s ", marker, title) + pad := width - len(label) + if pad < 0 { + return label[:width] + } + left := pad / 2 + right := pad - left + return strings.Repeat("─", left) + label + strings.Repeat("─", right) +} + +// cellAt returns the display text for a specific row in a pane, padded or +// clipped to exactly width columns. +func cellAt(lines []string, row, width int) string { + text := "" + if row < len(lines) { + text = lines[row] + } + // Strip any embedded newlines that would break the layout. + text = strings.ReplaceAll(text, "\n", " ") + + if len(text) > width { + return text[:width] + } + return text + strings.Repeat(" ", width-len(text)) +} diff --git a/internal/ui/syles.go b/internal/ui/syles.go new file mode 100644 index 00000000..0b08ff6a --- /dev/null +++ b/internal/ui/syles.go @@ -0,0 +1,105 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +// Package ui — styles.go centralises all ANSI SGR colour codes used by the +// hintents terminal UI. Using named constants rather than raw escape sequences +// in each widget makes it trivial to disable colour (set NoColor = true) or +// add new styles in one place. +// +// Compatibility note: all codes here are standard ANSI SGR sequences (ISO +// 6429). They are the same sequences tcell uses internally when writing to a +// VT-compatible terminal, so output is compatible with any terminal that +// supports tcell — which is the requirement stated in issue #1010. +package ui + +// NoColor disables all ANSI output when set to true. +// Useful for piped output or terminals that do not support colour. +// Mirror this flag into widgets.StatePanel with SetNoColor() as well. +var NoColor bool + +// ANSI SGR codes +const ( + ansiReset = "\033[0m" + + ansiBold = "\033[1m" + ansiDim = "\033[2m" + + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" + ansiBlue = "\033[34m" + ansiMagenta = "\033[35m" + ansiCyan = "\033[36m" + ansiWhite = "\033[37m" + + // Bold + colour — legible on both dark and light terminal themes. + ansiBoldRed = "\033[1;31m" + ansiBoldGreen = "\033[1;32m" + ansiBoldYellow = "\033[1;33m" + ansiBoldCyan = "\033[1;36m" + + // Dim + colour — "before" values in changed rows. + ansiDimRed = "\033[2;31m" + ansiDimGreen = "\033[2;32m" +) + +var styleMap = map[string]string{ + "red": ansiRed, + "green": ansiGreen, + "yellow": ansiYellow, + "blue": ansiBlue, + "magenta": ansiMagenta, + "cyan": ansiCyan, + "white": ansiWhite, + + "bold": ansiBold, + "dim": ansiDim, + + "bold-red": ansiBoldRed, + "bold-green": ansiBoldGreen, + "bold-yellow": ansiBoldYellow, + "bold-cyan": ansiBoldCyan, + + "dim-red": ansiDimRed, + "dim-green": ansiDimGreen, +} + +// Colorize wraps text in the ANSI sequence for style and appends a reset. +// It is a no-op when NoColor is true or the style name is not recognised. +func Colorize(text, style string) string { + if NoColor || style == "" { + return text + } + code, ok := styleMap[style] + if !ok { + return text + } + return code + text + ansiReset +} + +// DiffLegend returns a compact one-line legend describing the diff colour scheme. +// Suitable as the last line of the panel or in a status bar. +func DiffLegend() string { + if NoColor { + return "Legend: [+] added [-] removed [~] changed [ ] unchanged" + } + return "Legend: " + + Colorize("[+]", "bold-green") + " added " + + Colorize("[-]", "bold-red") + " removed " + + Colorize("[~]", "bold-yellow") + " changed " + + Colorize("[ ]", "dim") + " unchanged" +} + +// BorderStyle holds the characters used to draw pane borders. +type BorderStyle struct { + Horizontal string + Vertical string + Corner string +} + +// DefaultBorder is the ASCII border style used by all panels. +var DefaultBorder = BorderStyle{ + Horizontal: "─", + Vertical: "│", + Corner: "+", +} diff --git a/internal/ui/trace_view.go b/internal/ui/trace_view.go new file mode 100644 index 00000000..e1294b1c --- /dev/null +++ b/internal/ui/trace_view.go @@ -0,0 +1,318 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "fmt" + "strings" + + "github.com/dotandev/hintents/internal/trace" + "github.com/dotandev/hintents/internal/ui/widgets" +) + +type StateRow struct { + Key string + Value string +} + +type TraceView struct { + tree *trace.TreeRenderer + etrace *trace.ExecutionTrace + stateRows []StateRow + stateScroll int + stateSel int + diffPanel *widgets.StatePanel +} + +func NewTraceView(root *trace.TraceNode, etrace *trace.ExecutionTrace) *TraceView { + w, h := TermSize() + tv := &TraceView{ + tree: trace.NewTreeRenderer(w/2, h-3), + etrace: etrace, + diffPanel: widgets.NewStatePanel(), + } + tv.tree.RenderTree(root) + tv.refreshState() + tv.refreshDiff() + return tv +} + +func (tv *TraceView) Resize(w, h int) { + tv.tree = trace.NewTreeRenderer(w/2, h-3) +} +func (tv *TraceView) HandleKey(k Key, layout *SplitLayout) (done bool) { + switch k { + case KeyQuit: + return true + + case KeyTab: + layout.ToggleFocus() + + case KeyLeft: + layout.SetFocus(PaneTrace) + + case KeyRight: + if layout.ShowDiff { + layout.SetFocus(PaneDiff) + } else { + layout.SetFocus(PaneState) + } + + case KeyDiff: + layout.ToggleDiff() + case KeyUp: + switch layout.Focus { + case PaneTrace: + tv.tree.SelectUp() + tv.refreshState() + tv.refreshDiff() + case PaneState: + tv.stateScrollUp() + case PaneDiff: + tv.diffPanel.SelectUp() + } + + case KeyDown: + contentRows := layout.Height - 3 + switch layout.Focus { + case PaneTrace: + tv.tree.SelectDown() + tv.refreshState() + tv.refreshDiff() + case PaneState: + tv.stateScrollDown() + case PaneDiff: + tv.diffPanel.SelectDown(contentRows - 2) + } + + case KeyEnter: + if layout.Focus == PaneTrace { + if node := tv.tree.GetSelectedNode(); node != nil { + node.ToggleExpanded() + root := treeRoot(node) + tv.tree.RenderTree(root) + tv.refreshState() + tv.refreshDiff() + } + } + } + + return false +} + +// Render draws the complete split-screen frame using layout for dimensions +// and focus state. +func (tv *TraceView) Render(layout *SplitLayout) { + lw := layout.LeftWidth() + mw := layout.MiddleWidth() + rw := layout.RightWidth() + contentRows := layout.Height - 3 + if contentRows < 1 { + contentRows = 1 + } + + leftLines := tv.renderTraceLines(lw, contentRows) + middleLines := tv.renderStateLines(mw, contentRows) + var rightLines []string + if layout.ShowDiff { + rightLines = tv.diffPanel.Lines(rw, contentRows) + } + + layout.Render(leftLines, middleLines, rightLines) +} + +// refreshDiff updates the diff panel from the ExecutionTrace at the current step. +func (tv *TraceView) refreshDiff() { + if tv.etrace == nil || tv.diffPanel == nil { + return + } + step := tv.etrace.CurrentStep + var prev, curr *trace.ExecutionState + if step >= 0 && step < len(tv.etrace.States) { + s := tv.etrace.States[step] + curr = &s + } + if step > 0 && step-1 < len(tv.etrace.States) { + s := tv.etrace.States[step-1] + prev = &s + } + tv.diffPanel.SetStates(prev, curr) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Left pane — Trace tree +// ────────────────────────────────────────────────────────────────────────────── + +func (tv *TraceView) renderTraceLines(width, maxRows int) []string { + // Re-render the tree into a string and split on newlines. + raw := tv.tree.Render() + all := strings.Split(raw, "\n") + + // Clip to maxRows and pad to width. + lines := make([]string, maxRows) + for i := 0; i < maxRows; i++ { + text := "" + if i < len(all) { + text = all[i] + } + lines[i] = padOrClip(text, width) + } + return lines +} + +// ────────────────────────────────────────────────────────────────────────────── +// Right pane — State table +// ────────────────────────────────────────────────────────────────────────────── + +// refreshState rebuilds stateRows from the currently selected trace node. +func (tv *TraceView) refreshState() { + node := tv.tree.GetSelectedNode() + tv.stateRows = nodeToStateRows(node) + // Keep selection in bounds. + if tv.stateSel >= len(tv.stateRows) { + tv.stateSel = len(tv.stateRows) - 1 + } + if tv.stateSel < 0 { + tv.stateSel = 0 + } + tv.stateScroll = 0 +} + +func (tv *TraceView) stateScrollUp() { + if tv.stateSel > 0 { + tv.stateSel-- + } + if tv.stateSel < tv.stateScroll { + tv.stateScroll = tv.stateSel + } +} + +func (tv *TraceView) stateScrollDown() { + if tv.stateSel < len(tv.stateRows)-1 { + tv.stateSel++ + } +} + +func (tv *TraceView) renderStateLines(width, maxRows int) []string { + lines := make([]string, maxRows) + + // Header row. + keyW := width / 3 + valW := width - keyW - 3 // " │ " + if keyW < 4 { + keyW = 4 + } + if valW < 4 { + valW = 4 + } + header := fmt.Sprintf(" %-*s %s", keyW, "Key", "Value") + lines[0] = padOrClip(header, width) + + divider := " " + strings.Repeat("─", width-2) + lines[1] = padOrClip(divider, width) + + // Data rows starting at line 2. + visStart := tv.stateScroll + row := 2 + for i := visStart; i < len(tv.stateRows) && row < maxRows; i++ { + sr := tv.stateRows[i] + prefix := " " + if i == tv.stateSel { + prefix = "▸ " + } + key := padOrClip(sr.Key, keyW) + val := padOrClip(sr.Value, valW) + line := fmt.Sprintf("%s%-*s %s", prefix, keyW, key, val) + lines[row] = padOrClip(line, width) + row++ + } + + // Empty rows already zero-value strings (""); pad them. + for ; row < maxRows; row++ { + lines[row] = strings.Repeat(" ", width) + } + + if len(tv.stateRows) == 0 { + msg := " (no state for selected node)" + lines[2] = padOrClip(msg, width) + } + + return lines +} + +// nodeToStateRows converts a TraceNode into display rows for the state table. +func nodeToStateRows(node *trace.TraceNode) []StateRow { + if node == nil { + return nil + } + var rows []StateRow + + add := func(k, v string) { + rows = append(rows, StateRow{Key: k, Value: v}) + } + + add("type", node.Type) + if node.ContractID != "" { + add("contract_id", node.ContractID) + } + if node.Function != "" { + add("function", node.Function) + } + add("depth", fmt.Sprintf("%d", node.Depth)) + if node.EventData != "" { + add("event_data", node.EventData) + } + if node.Error != "" { + add("error", node.Error) + } + if node.CPUDelta != nil { + add("cpu_delta", fmt.Sprintf("%d instructions", *node.CPUDelta)) + } + if node.MemoryDelta != nil { + add("mem_delta", fmt.Sprintf("%d bytes", *node.MemoryDelta)) + } + if node.SourceRef != nil { + ref := node.SourceRef + loc := fmt.Sprintf("%s:%d", ref.File, ref.Line) + if ref.Column > 0 { + loc = fmt.Sprintf("%s:%d", loc, ref.Column) + } + add("source", loc) + if ref.Function != "" { + add("src_function", ref.Function) + } + } + add("children", fmt.Sprintf("%d", len(node.Children))) + if node.IsLeaf() { + add("leaf", "true") + } + if node.IsCrossContractCall() { + add("cross_contract", "true") + } + + return rows +} + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +// padOrClip pads s with spaces to exactly width, or clips it if longer. +func padOrClip(s string, width int) string { + if width <= 0 { + return "" + } + if len(s) >= width { + return s[:width] + } + return s + strings.Repeat(" ", width-len(s)) +} + +// treeRoot walks parent pointers to find the root TraceNode. +func treeRoot(n *trace.TraceNode) *trace.TraceNode { + for n.Parent != nil { + n = n.Parent + } + return n +} diff --git a/internal/ui/widgets/state_panel.go b/internal/ui/widgets/state_panel.go new file mode 100644 index 00000000..1a15ffa9 --- /dev/null +++ b/internal/ui/widgets/state_panel.go @@ -0,0 +1,444 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +// Package widgets provides reusable terminal UI panels for the hintents +// interactive trace viewer. +package widgets + +import ( + "fmt" + "sort" + "strings" + + "github.com/dotandev/hintents/internal/trace" +) + +// DiffKind classifies a single ledger entry change. +type DiffKind int + +const ( + DiffSame DiffKind = iota // key present in both states with the same value + DiffAdded // key only in current state (new entry) + DiffRemoved // key only in previous state (deleted entry) + DiffChanged // key in both states but value changed +) + +// String returns a short label for the diff kind used in status indicators. +func (d DiffKind) String() string { + switch d { + case DiffAdded: + return "+" + case DiffRemoved: + return "-" + case DiffChanged: + return "~" + default: + return " " + } +} + +// Style returns the colour name for this diff kind (matches ui.Colorize keys). +func (d DiffKind) Style() string { + switch d { + case DiffAdded: + return "green" + case DiffRemoved: + return "red" + case DiffChanged: + return "yellow" + default: + return "dim" + } +} + +// DiffEntry is a single row in the State Diff panel. +type DiffEntry struct { + Key string + OldValue string // empty for DiffAdded + NewValue string // empty for DiffRemoved + Kind DiffKind +} + +// ComputeDiff produces the ordered list of DiffEntry values that describe +// how HostState changed between prev and curr. +// +// When prev is nil (e.g. at step 0) all keys in curr are treated as DiffAdded. +// When curr is nil all keys in prev are treated as DiffRemoved. +func ComputeDiff(prev, curr *trace.ExecutionState) []DiffEntry { + var oldMap, newMap map[string]interface{} + if prev != nil { + oldMap = prev.HostState + } + if curr != nil { + newMap = curr.HostState + } + + // Collect all keys. + keySet := make(map[string]struct{}) + for k := range oldMap { + keySet[k] = struct{}{} + } + for k := range newMap { + keySet[k] = struct{}{} + } + + entries := make([]DiffEntry, 0, len(keySet)) + for k := range keySet { + oldVal, inOld := oldMap[k] + newVal, inNew := newMap[k] + + entry := DiffEntry{Key: k} + + switch { + case inOld && !inNew: + entry.Kind = DiffRemoved + entry.OldValue = formatValue(oldVal) + case !inOld && inNew: + entry.Kind = DiffAdded + entry.NewValue = formatValue(newVal) + default: + oldStr := formatValue(oldVal) + newStr := formatValue(newVal) + if oldStr == newStr { + entry.Kind = DiffSame + } else { + entry.Kind = DiffChanged + } + entry.OldValue = oldStr + entry.NewValue = newStr + } + entries = append(entries, entry) + } + + // Stable sort: changed/added/removed first, then same; alphabetical within groups. + sort.SliceStable(entries, func(i, j int) bool { + pi, pj := kindPriority(entries[i].Kind), kindPriority(entries[j].Kind) + if pi != pj { + return pi < pj + } + return entries[i].Key < entries[j].Key + }) + + return entries +} + +func kindPriority(k DiffKind) int { + switch k { + case DiffChanged: + return 0 + case DiffAdded: + return 1 + case DiffRemoved: + return 2 + default: + return 3 + } +} + +func formatValue(v interface{}) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + +// ───────────────────────────────────────────────────────────────────────────── + +// StatePanel is a resizable, scrollable three-column diff widget. +// +// It renders the delta of HostState between two consecutive ExecutionState +// values, using colour to highlight additions (green), removals (red), and +// modifications (yellow). +// +// Call SetStates whenever the user moves to a new step; call Lines() to +// obtain the pre-rendered string slice that SplitLayout can zip into its +// right pane. +type StatePanel struct { + entries []DiffEntry + scrollTop int // first visible row index into entries + selectedRow int // highlighted row index into entries + noColor bool +} + +// NewStatePanel creates an empty StatePanel. +func NewStatePanel() *StatePanel { + return &StatePanel{} +} + +// SetStates computes a fresh diff from prev → curr and resets scroll/selection. +// Either argument may be nil (step 0 has no previous state). +func (p *StatePanel) SetStates(prev, curr *trace.ExecutionState) { + p.entries = ComputeDiff(prev, curr) + p.scrollTop = 0 + p.selectedRow = 0 +} + +// SelectUp moves the highlighted row up by one. +func (p *StatePanel) SelectUp() { + if p.selectedRow > 0 { + p.selectedRow-- + if p.selectedRow < p.scrollTop { + p.scrollTop = p.selectedRow + } + } +} + +// SelectDown moves the highlighted row down by one. +func (p *StatePanel) SelectDown(visibleRows int) { + if p.selectedRow < len(p.entries)-1 { + p.selectedRow++ + if p.selectedRow >= p.scrollTop+visibleRows { + p.scrollTop = p.selectedRow - visibleRows + 1 + } + } +} + +// SelectedEntry returns the currently highlighted DiffEntry, or nil when the +// panel is empty. +func (p *StatePanel) SelectedEntry() *DiffEntry { + if p.selectedRow < 0 || p.selectedRow >= len(p.entries) { + return nil + } + e := p.entries[p.selectedRow] + return &e +} + +// Lines renders the panel into a slice of strings, each exactly width columns +// wide (padded or clipped). Layout: +// +// line 0: column headers (Key │ Old Value │ New Value) +// line 1: divider +// lines 2..N-2: data rows, colour-coded by DiffKind +// line N-1: legend or scroll indicator +// +// Colour scheme (legible on both light and dark terminals): +// +// DiffAdded: indicator bold-green, new value bold-green +// DiffRemoved: indicator bold-red, old value bold-red +// DiffChanged: indicator bold-yellow, old value dim-red, new value bold-green +// DiffSame: entire row dim +// Selected: indicator + key overridden to bold-cyan +func (p *StatePanel) Lines(width, maxRows int) []string { + if width < 12 { + width = 12 + } + lines := make([]string, maxRows) + + // ── Column widths ──────────────────────────────────────────────────────── + indicatorW := 2 + rest := width - indicatorW - 2 // two '│' separators + if rest < 9 { + rest = 9 + } + keyW := rest * 30 / 100 + oldW := (rest - keyW) / 2 + newW := rest - keyW - oldW + + // ── Header ─────────────────────────────────────────────────────────────── + header := fmt.Sprintf("%-*s%-*s│%-*s│%-*s", + indicatorW, "", + keyW, p.colorize("Key", "bold"), + oldW, p.colorize("Old Value", "bold"), + newW, p.colorize("New Value", "bold"), + ) + lines[0] = clip(header, width) + lines[1] = strings.Repeat("─", width) + + // ── Data rows ──────────────────────────────────────────────────────────── + // Reserve the last line for the legend/scroll indicator. + dataRows := maxRows - 3 // header + divider + legend + if dataRows < 0 { + dataRows = 0 + } + + for row := 0; row < dataRows; row++ { + idx := p.scrollTop + row + if idx >= len(p.entries) { + lines[row+2] = strings.Repeat(" ", width) + continue + } + e := p.entries[idx] + + // Per-column styles for this diff kind. + kindStyle := e.Kind.Style() // indicator + key style + oldStyle, newStyle := e.Kind.valueStyles() + + // Selected row overrides indicator and key to bold-cyan. + selected := idx == p.selectedRow + if selected { + kindStyle = "bold-cyan" + } + + indicator := p.colorize(e.Kind.String()+" ", kindStyle) + key := p.colorize(truncate(e.Key, keyW), kindStyle) + oldVal := p.colorize(truncate(e.OldValue, oldW), oldStyle) + newVal := p.colorize(truncate(e.NewValue, newW), newStyle) + + line := fmt.Sprintf("%s%-*s│%-*s│%-*s", + indicator, + keyW, key, + oldW, oldVal, + newW, newVal, + ) + lines[row+2] = clip(line, width) + } + + // Pad any unused data rows. + for row := dataRows; row > 0; row-- { + if idx := p.scrollTop + row - 1; idx >= len(p.entries) { + lines[row+1] = strings.Repeat(" ", width) + } + } + + // ── Last line: scroll indicator or legend ───────────────────────────── + lastRow := maxRows - 1 + if len(p.entries) > dataRows && dataRows > 0 { + total := len(p.entries) + end := p.scrollTop + dataRows + if end > total { + end = total + } + scroll := p.colorize( + fmt.Sprintf(" ─ %d–%d of %d ", p.scrollTop+1, end, total), + "dim", + ) + lines[lastRow] = clip(scroll+diffLegend(), width) + } else { + lines[lastRow] = clip(diffLegend(), width) + } + + // ── Empty state ────────────────────────────────────────────────────────── + if len(p.entries) == 0 && maxRows > 2 { + lines[2] = clip(p.colorize(" (no host-state changes at this step)", "dim"), width) + } + + return lines +} + +// Summary returns a one-line count string for use in status bars. +func (p *StatePanel) Summary() string { + added, removed, changed := 0, 0, 0 + for _, e := range p.entries { + switch e.Kind { + case DiffAdded: + added++ + case DiffRemoved: + removed++ + case DiffChanged: + changed++ + } + } + return fmt.Sprintf("+%d -%d ~%d", added, removed, changed) +} + +// SetNoColor disables ANSI colour output for this panel. +func (p *StatePanel) SetNoColor(v bool) { + p.noColor = v +} + +// colorize wraps text in a colour sequence unless p.noColor is set. +func (p *StatePanel) colorize(text, style string) string { + if p.noColor { + return text + } + return Colorize(text, style) +} + +// diffLegend returns a compact legend line for the panel footer. +// It mirrors ui.DiffLegend() but uses the local Colorize so the widgets +// package does not need to import the parent ui package. +func diffLegend() string { + return "Legend: " + + Colorize("[+]", "bold-green") + " added " + + Colorize("[-]", "bold-red") + " removed " + + Colorize("[~]", "bold-yellow") + " changed " + + Colorize("[ ]", "dim") + " unchanged" +} + +// valueStyles returns the ANSI style names to apply to the old-value and +// new-value columns for a given DiffKind. +// +// Design rationale: +// - DiffAdded: new value bold-green (clearly new, nothing to compare) +// - DiffRemoved: old value bold-red (clearly gone, nothing to compare) +// - DiffChanged: old value dim-red (secondary — was), new bold-green (primary — now) +// - DiffSame: both dim (visually recedes, not a change) +// +// Bold variants ensure legibility on light-background terminals where plain +// green/red can wash out against a pale background. +func (d DiffKind) valueStyles() (oldStyle, newStyle string) { + switch d { + case DiffAdded: + return "", "bold-green" + case DiffRemoved: + return "bold-red", "" + case DiffChanged: + return "dim-red", "bold-green" + default: // DiffSame + return "dim", "dim" + } +} + +// truncate clips s to at most n bytes, appending "…" when clipped. +func truncate(s string, n int) string { + if n <= 0 { + return "" + } + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +// clip pads or clips s to exactly n bytes (raw bytes, not runes). +func clip(s string, n int) string { + if n <= 0 { + return "" + } + if len(s) >= n { + return s[:n] + } + return s + strings.Repeat(" ", n-len(s)) +} + +// Colorize is re-exported so the widgets package can be used without importing +// the parent ui package, keeping the dependency graph acyclic. +// +// Supported style names match those in ui/styles.go: +// +// Plain: red, green, yellow, cyan, magenta, blue, white +// Intensity: bold, dim +// Combined: bold-red, bold-green, bold-yellow, bold-cyan +// dim-red, dim-green +func Colorize(text, style string) string { + const reset = "\033[0m" + codes := map[string]string{ + // Plain colours + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", + "white": "\033[37m", + // Intensity + "bold": "\033[1m", + "dim": "\033[2m", + // Bold + colour (high legibility on light and dark backgrounds) + "bold-red": "\033[1;31m", + "bold-green": "\033[1;32m", + "bold-yellow": "\033[1;33m", + "bold-cyan": "\033[1;36m", + // Dim + colour ("before" / secondary values) + "dim-red": "\033[2;31m", + "dim-green": "\033[2;32m", + } + code, ok := codes[style] + if !ok || style == "" { + return text + } + return code + text + reset +}