From 12be965c4c03ad29b982b98f531a6f6e73c6f0ab Mon Sep 17 00:00:00 2001 From: Ezedike-Evan Date: Mon, 30 Mar 2026 04:26:49 +0100 Subject: [PATCH 1/4] feat(ui): implement split-screen Trace vs State view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/ui/keys.go — Key type, KeyReader parsing raw stdin bytes and ANSI CSI escape sequences; TermSize() with $COLUMNS/$LINES fallback - Add internal/ui/layout.go — SplitLayout with ToggleFocus (Tab), left/right pane widths from SplitRatio, SIGWINCH listener via ListenResize(), side-by- side Render() zipping pre-built pane lines with │ divider; focused pane marked in border header - Add internal/ui/trace_view.go — TraceView wiring TreeRenderer to a state key-value table; HandleKey dispatches to focused pane; refreshState rebuilds table from selected node on every tree navigation event --- internal/ui/keys.go | 154 ++++++++++++++++++++ internal/ui/layout.go | 201 ++++++++++++++++++++++++++ internal/ui/trace_view.go | 290 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 645 insertions(+) create mode 100644 internal/ui/keys.go create mode 100644 internal/ui/layout.go create mode 100644 internal/ui/trace_view.go diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 00000000..7081b34b --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,154 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "bufio" + "fmt" + "os" +) + +type Key int + +const ( + KeyUnknown Key = iota + KeyTab + KeyUp + KeyDown + KeyLeft + KeyRight + KeyEnter + KeyQuit + KeySlash + KeyEscape +) + +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 "?" + } +} + +// KeyHelp returns a compact one-line help string for the status bar. +func KeyHelp() string { + return "Tab:switch-pane ↑↓:navigate Enter:expand q:quit /:search" +} + +type KeyReader struct { + r *bufio.Reader +} + +// NewKeyReader creates a KeyReader reading from os.Stdin. +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': // ASCII 0x09 + return KeyTab, nil + case '\r', '\n': // CR / LF + return KeyEnter, nil + case 'q', 'Q': + return KeyQuit, 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: // ESC — may be start of an ANSI escape sequence + return kr.readEscape() + case 0x03: // Ctrl-C + return KeyQuit, nil + } + return KeyUnknown, nil +} + +// readEscape parses ANSI CSI sequences after the leading ESC byte. +func (kr *KeyReader) readEscape() (Key, error) { + next, err := kr.r.ReadByte() + if err != nil { + return KeyEscape, nil // bare Esc + } + if next != '[' { + return KeyEscape, nil // ESC not followed by '[' — treat as Esc + } + + // Read CSI parameter bytes until a final byte in 0x40–0x7E + 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 +} + +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 +} \ No newline at end of file diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 00000000..25534e51 --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,201 @@ +// 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 is the left pane showing the execution trace tree. + PaneTrace Pane = iota + // PaneState is the right pane showing the state key-value table. + PaneState +) + +// String returns a display label for the pane. +func (p Pane) String() string { + if p == PaneTrace { + return "Trace" + } + return "State" +} + +type SplitLayout struct { + Width int + Height int + Focus Pane + + LeftTitle string + RightTitle string + SplitRatio float64 + + 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", + RightTitle: "State", + SplitRatio: 0.5, + resizeCh: make(chan struct{}, 1), + } +} + +// ToggleFocus switches keyboard focus to the other pane and returns the new +// active pane. This is the action bound to the Tab key. +func (l *SplitLayout) ToggleFocus() Pane { + if l.Focus == PaneTrace { + l.Focus = PaneState + } else { + l.Focus = PaneTrace + } + return l.Focus +} + +// SetFocus moves focus to the specified pane. +func (l *SplitLayout) SetFocus(p Pane) { + l.Focus = p +} + +// LeftWidth returns the number of columns allocated to the left (trace) pane, +// excluding the centre divider character. +func (l *SplitLayout) LeftWidth() int { + ratio := l.SplitRatio + if ratio <= 0 || ratio >= 1 { + ratio = 0.5 + } + w := int(float64(l.Width) * ratio) + if w < 10 { + w = 10 + } + return w +} + +// RightWidth returns the number of columns allocated to the right (state) pane. +func (l *SplitLayout) RightWidth() int { + return l.Width - l.LeftWidth() - 1 // –1 for the divider +} + +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, rightLines []string) { + lw := l.LeftWidth() + rw := l.RightWidth() + contentRows := l.Height - 3 + if contentRows < 1 { + contentRows = 1 + } + + sb := &strings.Builder{} + + // ── Top border ──────────────────────────────────────────────────────────── + sb.WriteString(l.borderRow(lw, rw)) + sb.WriteByte('\n') + + // ── Content rows ───────────────────────────────────────────────────────── + for row := 0; row < contentRows; row++ { + leftCell := cellAt(leftLines, row, lw) + rightCell := cellAt(rightLines, row, rw) + + sb.WriteString(l.panePrefix(PaneTrace)) + sb.WriteString(leftCell) + sb.WriteString(l.divider()) + sb.WriteString(l.panePrefix(PaneState)) + sb.WriteString(rightCell) + sb.WriteByte('\n') + } + + // ── Bottom border ──────────────────────────────────────────────────────── + bottom := "+" + strings.Repeat("-", lw) + "+" + strings.Repeat("-", rw) + "+" + sb.WriteString(bottom) + sb.WriteByte('\n') + + // ── Status bar ─────────────────────────────────────────────────────────── + status := fmt.Sprintf(" [focus: %s] %s", l.Focus, KeyHelp()) + if len(status) > l.Width { + status = status[:l.Width] + } + sb.WriteString(status) + + fmt.Print(sb.String()) +} + +// borderRow builds the top border string with centred pane titles. +// +// +──── Trace ─────+──── State ─────+ +func (l *SplitLayout) borderRow(lw, rw int) string { + leftLabel := l.fmtTitle(l.LeftTitle, l.Focus == PaneTrace, lw) + rightLabel := l.fmtTitle(l.RightTitle, l.Focus == PaneState, rw) + return "+" + leftLabel + "+" + rightLabel + "+" +} + +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) +} + +func (l *SplitLayout) divider() string { + return "│" +} + +// panePrefix is a hook for future per-pane colouring (currently a no-op). +func (l *SplitLayout) panePrefix(_ Pane) string { + return "" +} + +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)) +} \ No newline at end of file diff --git a/internal/ui/trace_view.go b/internal/ui/trace_view.go new file mode 100644 index 00000000..8b7f2276 --- /dev/null +++ b/internal/ui/trace_view.go @@ -0,0 +1,290 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "fmt" + "strings" + + "github.com/dotandev/hintents/internal/trace" +) + +// StateRow is a single key-value pair shown in the right pane. +type StateRow struct { + Key string + Value string +} + +// TraceView is the split-screen controller that wires a trace.TreeRenderer +// (left pane) to a state table (right pane). +// +// Typical lifecycle: +// +// tv := ui.NewTraceView(root) +// layout := ui.NewSplitLayout() +// resize := layout.ListenResize() +// kr := ui.NewKeyReader() +// +// for { +// tv.Render(layout) +// key, _ := kr.Read() +// if done := tv.HandleKey(key, layout); done { break } +// select { +// case <-resize: +// tv.Resize(layout.Width, layout.Height) +// default: +// } +// } +type TraceView struct { + tree *trace.TreeRenderer + stateRows []StateRow + stateScroll int + stateSel int +} + +func NewTraceView(root *trace.TraceNode) *TraceView { + w, h := TermSize() + tv := &TraceView{ + tree: trace.NewTreeRenderer(w/2, h-3), + } + tv.tree.RenderTree(root) + tv.refreshState() + 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: + layout.SetFocus(PaneState) + + case KeyUp: + if layout.Focus == PaneTrace { + tv.tree.SelectUp() + tv.refreshState() + } else { + tv.stateScrollUp() + } + + case KeyDown: + if layout.Focus == PaneTrace { + tv.tree.SelectDown() + tv.refreshState() + } else { + tv.stateScrollDown() + } + + case KeyEnter: + if layout.Focus == PaneTrace { + if node := tv.tree.GetSelectedNode(); node != nil { + node.ToggleExpanded() + root := treeRoot(node) + tv.tree.RenderTree(root) + tv.refreshState() + } + } + } + + return false +} + +func (tv *TraceView) Render(layout *SplitLayout) { + lw := layout.LeftWidth() + rw := layout.RightWidth() + contentRows := layout.Height - 3 + + leftLines := tv.renderTraceLines(lw, contentRows) + rightLines := tv.renderStateLines(rw, contentRows) + + layout.Render(leftLines, rightLines) +} + +// ────────────────────────────────────────────────────────────────────────────── +// 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 +} \ No newline at end of file From 6d5c83f34c0cdb547cab208b7db81d3cf5ace422 Mon Sep 17 00:00:00 2001 From: Ezedike-Evan Date: Mon, 30 Mar 2026 05:00:57 +0100 Subject: [PATCH 2/4] feat(ui): implement State Diff panel in interactive split-screen mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/ui/widgets/state_panel.go — DiffKind (added/removed/changed/same), ComputeDiff() diffs ExecutionState.HostState maps; StatePanel renders a 3-column table (indicator│Key│Old Value│New Value) with colour coding, proportional column widths, scrolling, and a Summary() status line - Add internal/ui/styles.go — centralised ANSI colour constants, Colorize(), NoColor global, DefaultBorder; widgets package exports its own copy to keep the dependency graph acyclic - Update internal/ui/layout.go — add PaneDiff pane constant, ShowDiff field, ToggleDiff(), MiddleWidth()/RightWidth() for three-pane column arithmetic, extend Render() signature to accept leftLines/middleLines/rightLines - Update internal/ui/trace_view.go — add diffPanel field and etrace param to NewTraceView; refreshDiff() wires ExecutionTrace steps to StatePanel.SetStates; HandleKey routes KeyDiff to ToggleDiff and PaneDiff nav to panel scroll - Update internal/ui/keys.go — add KeyDiff constant, map 'd'/'D' to it --- internal/ui/keys.go | 23 +- internal/ui/layout.go | 150 ++++++++---- internal/ui/syles.go | 75 ++++++ internal/ui/trace_view.go | 96 +++++--- internal/ui/widgets/state_panel.go | 368 +++++++++++++++++++++++++++++ 5 files changed, 617 insertions(+), 95 deletions(-) create mode 100644 internal/ui/syles.go create mode 100644 internal/ui/widgets/state_panel.go diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 7081b34b..f8f97f96 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -9,6 +9,7 @@ import ( "os" ) +// Key represents a normalised keyboard event. type Key int const ( @@ -22,6 +23,7 @@ const ( KeyQuit KeySlash KeyEscape + KeyDiff ) func (k Key) String() string { @@ -49,7 +51,6 @@ func (k Key) String() string { } } -// KeyHelp returns a compact one-line help string for the status bar. func KeyHelp() string { return "Tab:switch-pane ↑↓:navigate Enter:expand q:quit /:search" } @@ -58,7 +59,6 @@ type KeyReader struct { r *bufio.Reader } -// NewKeyReader creates a KeyReader reading from os.Stdin. func NewKeyReader() *KeyReader { return &KeyReader{r: bufio.NewReader(os.Stdin)} } @@ -70,12 +70,14 @@ func (kr *KeyReader) Read() (Key, error) { } switch b { - case '\t': // ASCII 0x09 + case '\t': return KeyTab, nil - case '\r', '\n': // CR / LF + 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': @@ -86,25 +88,23 @@ func (kr *KeyReader) Read() (Key, error) { return KeyRight, nil case '/': return KeySlash, nil - case 0x1b: // ESC — may be start of an ANSI escape sequence + case 0x1b: return kr.readEscape() - case 0x03: // Ctrl-C + case 0x03: return KeyQuit, nil } return KeyUnknown, nil } -// readEscape parses ANSI CSI sequences after the leading ESC byte. func (kr *KeyReader) readEscape() (Key, error) { next, err := kr.r.ReadByte() if err != nil { - return KeyEscape, nil // bare Esc + return KeyEscape, nil } if next != '[' { - return KeyEscape, nil // ESC not followed by '[' — treat as Esc + return KeyEscape, nil } - // Read CSI parameter bytes until a final byte in 0x40–0x7E var seq []byte for { c, err := kr.r.ReadByte() @@ -135,6 +135,9 @@ func (kr *KeyReader) readEscape() (Key, error) { 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) diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 25534e51..2919fdbb 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -15,18 +15,22 @@ import ( type Pane int const ( - // PaneTrace is the left pane showing the execution trace tree. PaneTrace Pane = iota - // PaneState is the right pane showing the state key-value table. PaneState + PaneDiff ) -// String returns a display label for the pane. func (p Pane) String() string { - if p == PaneTrace { + switch p { + case PaneTrace: return "Trace" + case PaneState: + return "State" + case PaneDiff: + return "Diff" + default: + return "?" } - return "State" } type SplitLayout struct { @@ -34,10 +38,14 @@ type SplitLayout struct { Height int Focus Pane - LeftTitle string - RightTitle string + LeftTitle string + MiddleTitle string + RightTitle string + SplitRatio float64 + ShowDiff bool + resizeCh chan struct{} } @@ -45,38 +53,50 @@ type SplitLayout struct { func NewSplitLayout() *SplitLayout { w, h := TermSize() return &SplitLayout{ - Width: w, - Height: h, - Focus: PaneTrace, - LeftTitle: "Trace", - RightTitle: "State", - SplitRatio: 0.5, - resizeCh: make(chan struct{}, 1), + Width: w, + Height: h, + Focus: PaneTrace, + LeftTitle: "Trace", + MiddleTitle: "State", + RightTitle: "Diff", + SplitRatio: 0.4, + resizeCh: make(chan struct{}, 1), } } -// ToggleFocus switches keyboard focus to the other pane and returns the new -// active pane. This is the action bound to the Tab key. func (l *SplitLayout) ToggleFocus() Pane { - if l.Focus == PaneTrace { + switch l.Focus { + case PaneTrace: l.Focus = PaneState - } else { + case PaneState: + if l.ShowDiff { + l.Focus = PaneDiff + } else { + l.Focus = PaneTrace + } + default: // PaneDiff l.Focus = PaneTrace } return l.Focus } -// SetFocus moves focus to the specified pane. func (l *SplitLayout) SetFocus(p Pane) { l.Focus = p } -// LeftWidth returns the number of columns allocated to the left (trace) pane, -// excluding the centre divider character. +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.5 + ratio = 0.4 } w := int(float64(l.Width) * ratio) if w < 10 { @@ -85,9 +105,30 @@ func (l *SplitLayout) LeftWidth() int { return w } -// RightWidth returns the number of columns allocated to the right (state) pane. +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 { - return l.Width - l.LeftWidth() - 1 // –1 for the divider + 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{} { @@ -111,9 +152,11 @@ func (l *SplitLayout) ListenResize() <-chan struct{} { return l.resizeCh } -func (l *SplitLayout) Render(leftLines, rightLines []string) { +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 @@ -122,29 +165,37 @@ func (l *SplitLayout) Render(leftLines, rightLines []string) { sb := &strings.Builder{} // ── Top border ──────────────────────────────────────────────────────────── - sb.WriteString(l.borderRow(lw, rw)) + sb.WriteString(l.borderRow(lw, mw, rw)) sb.WriteByte('\n') // ── Content rows ───────────────────────────────────────────────────────── for row := 0; row < contentRows; row++ { - leftCell := cellAt(leftLines, row, lw) - rightCell := cellAt(rightLines, row, rw) - - sb.WriteString(l.panePrefix(PaneTrace)) - sb.WriteString(leftCell) - sb.WriteString(l.divider()) - sb.WriteString(l.panePrefix(PaneState)) - sb.WriteString(rightCell) + 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("-", rw) + "+" + bottom := "+" + strings.Repeat("─", lw) + "+" + strings.Repeat("─", mw) + "+" + if l.ShowDiff && rw > 0 { + bottom += strings.Repeat("─", rw) + "+" + } sb.WriteString(bottom) sb.WriteByte('\n') // ── Status bar ─────────────────────────────────────────────────────────── - status := fmt.Sprintf(" [focus: %s] %s", l.Focus, KeyHelp()) + 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] } @@ -153,13 +204,16 @@ func (l *SplitLayout) Render(leftLines, rightLines []string) { fmt.Print(sb.String()) } -// borderRow builds the top border string with centred pane titles. -// -// +──── Trace ─────+──── State ─────+ -func (l *SplitLayout) borderRow(lw, rw int) string { - leftLabel := l.fmtTitle(l.LeftTitle, l.Focus == PaneTrace, lw) - rightLabel := l.fmtTitle(l.RightTitle, l.Focus == PaneState, rw) - return "+" + leftLabel + "+" + rightLabel + "+" +// 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 { @@ -177,15 +231,9 @@ func (l *SplitLayout) fmtTitle(title string, focused bool, width int) string { return strings.Repeat("─", left) + label + strings.Repeat("─", right) } -func (l *SplitLayout) divider() string { - return "│" -} - -// panePrefix is a hook for future per-pane colouring (currently a no-op). -func (l *SplitLayout) panePrefix(_ Pane) string { - return "" -} +// 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) { diff --git a/internal/ui/syles.go b/internal/ui/syles.go new file mode 100644 index 00000000..66a6389f --- /dev/null +++ b/internal/ui/syles.go @@ -0,0 +1,75 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiDim = "\033[2m" + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" + ansiCyan = "\033[36m" + ansiMagenta = "\033[35m" +) + +// NoColor disables all ANSI output when set to true. +// Useful for piped output or terminals that do not support colour. +var NoColor bool + +// Colorize wraps text in the given ANSI colour sequence. +// It is a no-op when NoColor is true or style is empty. +func Colorize(text, style string) string { + if NoColor || style == "" { + return text + } + var code string + switch style { + case "bold": + code = ansiBold + case "dim": + code = ansiDim + case "red": + code = ansiRed + case "green": + code = ansiGreen + case "yellow": + code = ansiYellow + case "cyan": + code = ansiCyan + case "magenta": + code = ansiMagenta + default: + return text + } + return code + text + ansiReset +} + +// DiffColors maps DiffKind values to their display style names. +var DiffColors = map[string]string{ + "added": "green", + "removed": "red", + "changed": "yellow", + "same": "dim", +} + +// BorderStyle holds the characters used to draw pane borders. +type BorderStyle struct { + Horizontal string + Vertical string + Corner string + Cross string + TeeLeft string + TeeRight string +} + +// DefaultBorder is the ASCII border style used by all panels. +var DefaultBorder = BorderStyle{ + Horizontal: "─", + Vertical: "│", + Corner: "+", + Cross: "+", + TeeLeft: "+", + TeeRight: "+", +} \ No newline at end of file diff --git a/internal/ui/trace_view.go b/internal/ui/trace_view.go index 8b7f2276..577941a1 100644 --- a/internal/ui/trace_view.go +++ b/internal/ui/trace_view.go @@ -8,55 +8,39 @@ import ( "strings" "github.com/dotandev/hintents/internal/trace" + "github.com/dotandev/hintents/internal/ui/widgets" ) -// StateRow is a single key-value pair shown in the right pane. type StateRow struct { Key string Value string } -// TraceView is the split-screen controller that wires a trace.TreeRenderer -// (left pane) to a state table (right pane). -// -// Typical lifecycle: -// -// tv := ui.NewTraceView(root) -// layout := ui.NewSplitLayout() -// resize := layout.ListenResize() -// kr := ui.NewKeyReader() -// -// for { -// tv.Render(layout) -// key, _ := kr.Read() -// if done := tv.HandleKey(key, layout); done { break } -// select { -// case <-resize: -// tv.Resize(layout.Width, layout.Height) -// default: -// } -// } type TraceView struct { - tree *trace.TreeRenderer - stateRows []StateRow + tree *trace.TreeRenderer + etrace *trace.ExecutionTrace + stateRows []StateRow stateScroll int - stateSel int + stateSel int + diffPanel *widgets.StatePanel } -func NewTraceView(root *trace.TraceNode) *TraceView { +func NewTraceView(root *trace.TraceNode, etrace *trace.ExecutionTrace) *TraceView { w, h := TermSize() tv := &TraceView{ - tree: trace.NewTreeRenderer(w/2, h-3), + 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: @@ -69,22 +53,37 @@ func (tv *TraceView) HandleKey(k Key, layout *SplitLayout) (done bool) { layout.SetFocus(PaneTrace) case KeyRight: - layout.SetFocus(PaneState) + if layout.ShowDiff { + layout.SetFocus(PaneDiff) + } else { + layout.SetFocus(PaneState) + } + case KeyDiff: + layout.ToggleDiff() case KeyUp: - if layout.Focus == PaneTrace { + switch layout.Focus { + case PaneTrace: tv.tree.SelectUp() tv.refreshState() - } else { + tv.refreshDiff() + case PaneState: tv.stateScrollUp() + case PaneDiff: + tv.diffPanel.SelectUp() } case KeyDown: - if layout.Focus == PaneTrace { + contentRows := layout.Height - 3 + switch layout.Focus { + case PaneTrace: tv.tree.SelectDown() tv.refreshState() - } else { + tv.refreshDiff() + case PaneState: tv.stateScrollDown() + case PaneDiff: + tv.diffPanel.SelectDown(contentRows - 2) } case KeyEnter: @@ -94,6 +93,7 @@ func (tv *TraceView) HandleKey(k Key, layout *SplitLayout) (done bool) { root := treeRoot(node) tv.tree.RenderTree(root) tv.refreshState() + tv.refreshDiff() } } } @@ -101,15 +101,43 @@ func (tv *TraceView) HandleKey(k Key, layout *SplitLayout) (done bool) { 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) - rightLines := tv.renderStateLines(rw, contentRows) + middleLines := tv.renderStateLines(mw, contentRows) + var rightLines []string + if layout.ShowDiff { + rightLines = tv.diffPanel.Lines(rw, contentRows) + } + + layout.Render(leftLines, middleLines, rightLines) +} - layout.Render(leftLines, 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) } // ────────────────────────────────────────────────────────────────────────────── diff --git a/internal/ui/widgets/state_panel.go b/internal/ui/widgets/state_panel.go new file mode 100644 index 00000000..d7580334 --- /dev/null +++ b/internal/ui/widgets/state_panel.go @@ -0,0 +1,368 @@ +// 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. + +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. +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). The first two lines are always the column headers +// and a divider. maxRows controls the total number of lines returned (including +// headers) so the caller can size the pane precisely. +// +// Colour escape sequences are included unless p.noColor is true. +func (p *StatePanel) Lines(width, maxRows int) []string { + if width < 12 { + width = 12 + } + lines := make([]string, maxRows) + + // ── Column widths ──────────────────────────────────────────────────────── + // Layout: [indicator][key][│][old][│][new] + // indicator = 2 chars ("+ ", "- ", "~ ", " ") + // remainder split: key 30 %, old 35 %, new 35 % + 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) + + divider := strings.Repeat("─", width) + lines[1] = divider + + // ── Data rows ──────────────────────────────────────────────────────────── + dataRows := maxRows - 2 // lines 2..maxRows-1 + 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] + style := e.Kind.Style() + + indicator := p.colorize(e.Kind.String()+" ", style) + key := p.colorize(truncate(e.Key, keyW), style) + oldVal := truncate(e.OldValue, oldW) + newVal := truncate(e.NewValue, newW) + + // Highlight the selected row with inverted style. + if idx == p.selectedRow { + indicator = p.colorize("▸ ", "cyan") + key = p.colorize(e.Key, "cyan") + if len(key) > keyW { + key = key[:keyW] + } + } + + line := fmt.Sprintf("%s%-*s│%-*s│%-*s", + indicator, + keyW, key, + oldW, oldVal, + newW, newVal, + ) + lines[row+2] = clip(line, width) + } + + // ── Scroll indicator (overwrites last data row when needed) ────────────── + if len(p.entries) > dataRows && dataRows > 0 { + total := len(p.entries) + end := p.scrollTop + dataRows + if end > total { + end = total + } + indicator := p.colorize( + fmt.Sprintf(" ─ %d–%d of %d entries ─", p.scrollTop+1, end, total), + "dim", + ) + lines[maxRows-1] = clip(indicator, 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) +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +// 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. +func Colorize(text, style string) string { + const reset = "\033[0m" + codes := map[string]string{ + "bold": "\033[1m", + "dim": "\033[2m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "cyan": "\033[36m", + "magenta": "\033[35m", + } + code, ok := codes[style] + if !ok || style == "" { + return text + } + return code + text + reset +} \ No newline at end of file From 234f6363283989aa4a873bd01b188422896e8b17 Mon Sep 17 00:00:00 2001 From: Ezedike-Evan Date: Mon, 30 Mar 2026 05:31:28 +0100 Subject: [PATCH 3/4] refactor: enhance StatePanel UI with improved colour-coded diff styling and a persistent footer legend --- internal/ui/syles.go | 106 +++++++++++++-------- internal/ui/widgets/state_panel.go | 148 ++++++++++++++++++++++------- 2 files changed, 181 insertions(+), 73 deletions(-) diff --git a/internal/ui/syles.go b/internal/ui/syles.go index 66a6389f..b4b519a3 100644 --- a/internal/ui/syles.go +++ b/internal/ui/syles.go @@ -1,57 +1,93 @@ // 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" + ansiReset = "\033[0m" + + ansiBold = "\033[1m" + ansiDim = "\033[2m" + ansiRed = "\033[31m" ansiGreen = "\033[32m" ansiYellow = "\033[33m" - ansiCyan = "\033[36m" + 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" ) -// NoColor disables all ANSI output when set to true. -// Useful for piped output or terminals that do not support colour. -var NoColor bool +var styleMap = map[string]string{ + "red": ansiRed, + "green": ansiGreen, + "yellow": ansiYellow, + "blue": ansiBlue, + "magenta": ansiMagenta, + "cyan": ansiCyan, + "white": ansiWhite, + + "bold": ansiBold, + "dim": ansiDim, -// Colorize wraps text in the given ANSI colour sequence. -// It is a no-op when NoColor is true or style is empty. + "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 } - var code string - switch style { - case "bold": - code = ansiBold - case "dim": - code = ansiDim - case "red": - code = ansiRed - case "green": - code = ansiGreen - case "yellow": - code = ansiYellow - case "cyan": - code = ansiCyan - case "magenta": - code = ansiMagenta - default: + code, ok := styleMap[style] + if !ok { return text } return code + text + ansiReset } -// DiffColors maps DiffKind values to their display style names. -var DiffColors = map[string]string{ - "added": "green", - "removed": "red", - "changed": "yellow", - "same": "dim", +// 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. @@ -59,9 +95,6 @@ type BorderStyle struct { Horizontal string Vertical string Corner string - Cross string - TeeLeft string - TeeRight string } // DefaultBorder is the ASCII border style used by all panels. @@ -69,7 +102,4 @@ var DefaultBorder = BorderStyle{ Horizontal: "─", Vertical: "│", Corner: "+", - Cross: "+", - TeeLeft: "+", - TeeRight: "+", } \ No newline at end of file diff --git a/internal/ui/widgets/state_panel.go b/internal/ui/widgets/state_panel.go index d7580334..cd452924 100644 --- a/internal/ui/widgets/state_panel.go +++ b/internal/ui/widgets/state_panel.go @@ -3,7 +3,6 @@ // Package widgets provides reusable terminal UI panels for the hintents // interactive trace viewer. - package widgets import ( @@ -62,7 +61,9 @@ type DiffEntry struct { // 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 { @@ -144,6 +145,14 @@ func formatValue(v interface{}) string { // ───────────────────────────────────────────────────────────────────────────── // 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 @@ -195,11 +204,20 @@ func (p *StatePanel) SelectedEntry() *DiffEntry { } // Lines renders the panel into a slice of strings, each exactly width columns -// wide (padded or clipped). The first two lines are always the column headers -// and a divider. maxRows controls the total number of lines returned (including -// headers) so the caller can size the pane precisely. +// 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 escape sequences are included unless p.noColor is true. +// 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 @@ -207,9 +225,6 @@ func (p *StatePanel) Lines(width, maxRows int) []string { lines := make([]string, maxRows) // ── Column widths ──────────────────────────────────────────────────────── - // Layout: [indicator][key][│][old][│][new] - // indicator = 2 chars ("+ ", "- ", "~ ", " ") - // remainder split: key 30 %, old 35 %, new 35 % indicatorW := 2 rest := width - indicatorW - 2 // two '│' separators if rest < 9 { @@ -227,12 +242,11 @@ func (p *StatePanel) Lines(width, maxRows int) []string { newW, p.colorize("New Value", "bold"), ) lines[0] = clip(header, width) - - divider := strings.Repeat("─", width) - lines[1] = divider + lines[1] = strings.Repeat("─", width) // ── Data rows ──────────────────────────────────────────────────────────── - dataRows := maxRows - 2 // lines 2..maxRows-1 + // Reserve the last line for the legend/scroll indicator. + dataRows := maxRows - 3 // header + divider + legend if dataRows < 0 { dataRows = 0 } @@ -244,22 +258,22 @@ func (p *StatePanel) Lines(width, maxRows int) []string { continue } e := p.entries[idx] - style := e.Kind.Style() - - indicator := p.colorize(e.Kind.String()+" ", style) - key := p.colorize(truncate(e.Key, keyW), style) - oldVal := truncate(e.OldValue, oldW) - newVal := truncate(e.NewValue, newW) - - // Highlight the selected row with inverted style. - if idx == p.selectedRow { - indicator = p.colorize("▸ ", "cyan") - key = p.colorize(e.Key, "cyan") - if len(key) > keyW { - key = key[:keyW] - } + + // 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, @@ -269,18 +283,28 @@ func (p *StatePanel) Lines(width, maxRows int) []string { lines[row+2] = clip(line, width) } - // ── Scroll indicator (overwrites last data row when needed) ────────────── + // 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 } - indicator := p.colorize( - fmt.Sprintf(" ─ %d–%d of %d entries ─", p.scrollTop+1, end, total), + scroll := p.colorize( + fmt.Sprintf(" ─ %d–%d of %d ", p.scrollTop+1, end, total), "dim", ) - lines[maxRows-1] = clip(indicator, width) + lines[lastRow] = clip(scroll+diffLegend(), width) + } else { + lines[lastRow] = clip(diffLegend(), width) } // ── Empty state ────────────────────────────────────────────────────────── @@ -320,7 +344,42 @@ func (p *StatePanel) colorize(text, style string) string { return Colorize(text, style) } -// ── Internal helpers ────────────────────────────────────────────────────────── +// 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 { @@ -349,16 +408,35 @@ func clip(s string, n int) string { // 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{ - "bold": "\033[1m", - "dim": "\033[2m", + // Plain colours "red": "\033[31m", "green": "\033[32m", "yellow": "\033[33m", - "cyan": "\033[36m", + "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 == "" { From 850b28659967ce95d5d60f7a0d9200f1897cea54 Mon Sep 17 00:00:00 2001 From: That guy <120946193+ezedike-evan@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:00:53 +0000 Subject: [PATCH 4/4] fix: apply gofmt formatting --- internal/ui/keys.go | 2 +- internal/ui/layout.go | 5 ++--- internal/ui/syles.go | 2 +- internal/ui/trace_view.go | 2 +- internal/ui/widgets/state_panel.go | 12 +++++------- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/internal/ui/keys.go b/internal/ui/keys.go index f8f97f96..4f80ee97 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -154,4 +154,4 @@ func readEnvInt(name string, fallback int) int { return n } return fallback -} \ No newline at end of file +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 2919fdbb..4f180049 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -36,7 +36,7 @@ func (p Pane) String() string { type SplitLayout struct { Width int Height int - Focus Pane + Focus Pane LeftTitle string MiddleTitle string @@ -231,7 +231,6 @@ func (l *SplitLayout) fmtTitle(title string, focused bool, width int) string { 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 { @@ -246,4 +245,4 @@ func cellAt(lines []string, row, width int) string { return text[:width] } return text + strings.Repeat(" ", width-len(text)) -} \ No newline at end of file +} diff --git a/internal/ui/syles.go b/internal/ui/syles.go index b4b519a3..0b08ff6a 100644 --- a/internal/ui/syles.go +++ b/internal/ui/syles.go @@ -102,4 +102,4 @@ var DefaultBorder = BorderStyle{ Horizontal: "─", Vertical: "│", Corner: "+", -} \ No newline at end of file +} diff --git a/internal/ui/trace_view.go b/internal/ui/trace_view.go index 577941a1..e1294b1c 100644 --- a/internal/ui/trace_view.go +++ b/internal/ui/trace_view.go @@ -315,4 +315,4 @@ func treeRoot(n *trace.TraceNode) *trace.TraceNode { n = n.Parent } return n -} \ No newline at end of file +} diff --git a/internal/ui/widgets/state_panel.go b/internal/ui/widgets/state_panel.go index cd452924..1a15ffa9 100644 --- a/internal/ui/widgets/state_panel.go +++ b/internal/ui/widgets/state_panel.go @@ -260,7 +260,7 @@ func (p *StatePanel) Lines(width, maxRows int) []string { e := p.entries[idx] // Per-column styles for this diff kind. - kindStyle := e.Kind.Style() // indicator + key style + kindStyle := e.Kind.Style() // indicator + key style oldStyle, newStyle := e.Kind.valueStyles() // Selected row overrides indicator and key to bold-cyan. @@ -270,9 +270,9 @@ func (p *StatePanel) Lines(width, maxRows int) []string { } 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) + 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, @@ -379,8 +379,6 @@ func (d DiffKind) valueStyles() (oldStyle, newStyle string) { } } - - // truncate clips s to at most n bytes, appending "…" when clipped. func truncate(s string, n int) string { if n <= 0 { @@ -443,4 +441,4 @@ func Colorize(text, style string) string { return text } return code + text + reset -} \ No newline at end of file +}