From c9101f7634ac0bdb09c6c9e4f6f43e831d6c4b96 Mon Sep 17 00:00:00 2001 From: yasunogithub Date: Tue, 17 Feb 2026 20:36:38 +0900 Subject: [PATCH 1/2] feat: add interactive TUI dashboard command Add `entire dashboard` with four tabs (Sessions, Checkpoints, Active, Settings) using bubbletea/lipgloss. Includes accessible text-mode fallback, filter search, checkpoint rewind with PreviewRewind safety check, and rune-safe input handling. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 1e466c84838a --- cmd/entire/cli/dashboard/accessible.go | 250 ++++++++++++++ cmd/entire/cli/dashboard/active_tab.go | 239 ++++++++++++++ cmd/entire/cli/dashboard/checkpoints_tab.go | 345 ++++++++++++++++++++ cmd/entire/cli/dashboard/dashboard.go | 316 ++++++++++++++++++ cmd/entire/cli/dashboard/data.go | 122 +++++++ cmd/entire/cli/dashboard/sessions_tab.go | 258 +++++++++++++++ cmd/entire/cli/dashboard/settings_tab.go | 171 ++++++++++ cmd/entire/cli/dashboard/styles.go | 80 +++++ cmd/entire/cli/dashboard_cmd.go | 126 +++++++ cmd/entire/cli/root.go | 1 + cmd/entire/cli/strategy/auto_commit.go | 2 +- cmd/entire/cli/strategy/manual_commit.go | 4 +- cmd/entire/cli/strategy/registry.go | 4 +- go.mod | 4 +- 14 files changed, 1915 insertions(+), 7 deletions(-) create mode 100644 cmd/entire/cli/dashboard/accessible.go create mode 100644 cmd/entire/cli/dashboard/active_tab.go create mode 100644 cmd/entire/cli/dashboard/checkpoints_tab.go create mode 100644 cmd/entire/cli/dashboard/dashboard.go create mode 100644 cmd/entire/cli/dashboard/data.go create mode 100644 cmd/entire/cli/dashboard/sessions_tab.go create mode 100644 cmd/entire/cli/dashboard/settings_tab.go create mode 100644 cmd/entire/cli/dashboard/styles.go create mode 100644 cmd/entire/cli/dashboard_cmd.go diff --git a/cmd/entire/cli/dashboard/accessible.go b/cmd/entire/cli/dashboard/accessible.go new file mode 100644 index 000000000..27fc0dd8e --- /dev/null +++ b/cmd/entire/cli/dashboard/accessible.go @@ -0,0 +1,250 @@ +package dashboard + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/huh" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// RunAccessible runs a text-based menu for accessibility mode. +func RunAccessible(w io.Writer) error { + for { + var choice string + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Entire Dashboard"). + Options( + huh.NewOption("View sessions", "sessions"), + huh.NewOption("View checkpoints", "checkpoints"), + huh.NewOption("View active sessions", "active"), + huh.NewOption("View settings", "settings"), + huh.NewOption("Quit", "quit"), + ). + Value(&choice), + ), + ).WithAccessible(true) + + if err := form.Run(); err != nil { + return fmt.Errorf("menu selection failed: %w", err) + } + + switch choice { + case "sessions": + if err := showAccessibleSessions(w); err != nil { + fmt.Fprintf(w, "Error: %v\n", err) + } + case "checkpoints": + if err := showAccessibleCheckpoints(w); err != nil { + fmt.Fprintf(w, "Error: %v\n", err) + } + case "active": + if err := showAccessibleActive(w); err != nil { + fmt.Fprintf(w, "Error: %v\n", err) + } + case "settings": + if err := showAccessibleSettings(w); err != nil { + fmt.Fprintf(w, "Error: %v\n", err) + } + case "quit": + return nil + } + + fmt.Fprintln(w) + } +} + +func showAccessibleSessions(w io.Writer) error { + sessions, err := strategy.ListSessions() + if err != nil { + return fmt.Errorf("loading sessions: %w", err) + } + + if len(sessions) == 0 { + fmt.Fprintln(w, "\nNo sessions found.") + return nil + } + + fmt.Fprintf(w, "\nSessions (%d):\n", len(sessions)) + fmt.Fprintln(w, strings.Repeat("-", 60)) + + for _, s := range sessions { + shortID := s.ID + if len(shortID) > 12 { + shortID = shortID[:12] + } + desc := s.Description + if desc == "" || desc == strategy.NoDescription { + desc = "(no description)" + } else { + desc = truncate(desc, 50) + } + fmt.Fprintf(w, " %-12s %s %s %d checkpoints\n", + shortID, desc, timeAgo(s.StartTime), len(s.Checkpoints)) + } + + return nil +} + +func showAccessibleCheckpoints(w io.Writer) error { + strat := getConfiguredStrategy() + if strat == nil { + return errors.New("no strategy configured") + } + + points, err := strat.GetRewindPoints(50) + if err != nil { + return fmt.Errorf("loading checkpoints: %w", err) + } + + if len(points) == 0 { + fmt.Fprintln(w, "\nNo checkpoints found.") + return nil + } + + fmt.Fprintf(w, "\nCheckpoints (%d):\n", len(points)) + fmt.Fprintln(w, strings.Repeat("-", 60)) + + for _, p := range points { + cpType := cpTypeSession + if p.IsTaskCheckpoint { + cpType = cpTypeTask + } + if p.IsLogsOnly { + cpType = cpTypeCommitted + } + + cpID := p.CheckpointID.String() + if cpID == "" { + cpID = truncate(p.ID, 12) + } + + prompt := truncate(p.SessionPrompt, 40) + if prompt == "" { + prompt = truncate(p.Message, 40) + } + + fmt.Fprintf(w, " %-12s [%-9s] %-10s %s\n", + cpID, cpType, timeAgo(p.Date), prompt) + } + + return nil +} + +func showAccessibleActive(w io.Writer) error { + store, err := session.NewStateStore() + if err != nil { + return fmt.Errorf("creating state store: %w", err) + } + + states, err := store.List(context.Background()) + if err != nil { + return fmt.Errorf("listing sessions: %w", err) + } + + // Filter active + var active []*session.State + for _, s := range states { + if s.EndedAt == nil { + active = append(active, s) + } + } + + if len(active) == 0 { + fmt.Fprintln(w, "\nNo active sessions.") + return nil + } + + fmt.Fprintf(w, "\nActive Sessions (%d):\n", len(active)) + fmt.Fprintln(w, strings.Repeat("-", 60)) + + for _, s := range active { + shortID := s.SessionID + if len(shortID) > 7 { + shortID = shortID[:7] + } + + agentLabel := string(s.AgentType) + if agentLabel == "" { + agentLabel = "(unknown)" + } + + phase := string(s.Phase) + if phase == "" { + phase = "idle" + } + + prompt := truncate(s.FirstPrompt, 40) + + fmt.Fprintf(w, " %-9s %-8s %-14s %s %s\n", + shortID, phase, agentLabel, timeAgo(s.StartedAt), prompt) + } + + return nil +} + +func showAccessibleSettings(w io.Writer) error { + s, err := settings.Load() + if err != nil { + return fmt.Errorf("loading settings: %w", err) + } + + fmt.Fprintln(w, "\nSettings:") + fmt.Fprintln(w, strings.Repeat("-", 40)) + + enabledStr := "Enabled" + if !s.Enabled { + enabledStr = "Disabled" + } + fmt.Fprintf(w, " Status: %s\n", enabledStr) + fmt.Fprintf(w, " Strategy: %s\n", s.Strategy) + + logLevel := s.LogLevel + if logLevel == "" { + logLevel = "info (default)" + } + fmt.Fprintf(w, " Log Level: %s\n", logLevel) + + telemetryStr := "not configured" + if s.Telemetry != nil { + if *s.Telemetry { + telemetryStr = "opted in" + } else { + telemetryStr = "opted out" + } + } + fmt.Fprintf(w, " Telemetry: %s\n", telemetryStr) + + fmt.Fprintln(w, "\nInstalled Agents:") + agents := agent.List() + if len(agents) == 0 { + fmt.Fprintln(w, " (none)") + } + for _, name := range agents { + ag, agErr := agent.Get(name) + if agErr != nil { + continue + } + hooksStr := "no hooks" + if hs, ok := ag.(agent.HookSupport); ok { + if hs.AreHooksInstalled() { + hooksStr = "hooks installed" + } else { + hooksStr = "hooks not installed" + } + } + fmt.Fprintf(w, " %s (%s) - %s\n", ag.Type(), name, hooksStr) + } + + return nil +} diff --git a/cmd/entire/cli/dashboard/active_tab.go b/cmd/entire/cli/dashboard/active_tab.go new file mode 100644 index 000000000..0ecee061e --- /dev/null +++ b/cmd/entire/cli/dashboard/active_tab.go @@ -0,0 +1,239 @@ +package dashboard + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/entireio/cli/cmd/entire/cli/session" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutating setters, value receivers for update/view +type activeModel struct { + states []*session.State + err error + cursor int + scrollPos int + width int + height int + showDetail bool +} + +func newActiveModel() activeModel { + return activeModel{} +} + +func (m *activeModel) setSize(width, height int) { + m.width = width + m.height = height +} + +func (m *activeModel) setSessions(states []*session.State) { + // Filter to active sessions only (no EndedAt) + var active []*session.State + for _, s := range states { + if s.EndedAt == nil { + active = append(active, s) + } + } + m.states = active +} + +func (m activeModel) update(msg tea.Msg) (activeModel, tea.Cmd) { //nolint:unparam // tea.Cmd needed for consistent tab interface + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case keyJ, keyDown: + if m.showDetail { + m.scrollPos++ + } else if m.cursor < len(m.states)-1 { + m.cursor++ + } + case keyK, keyUp: + if m.showDetail { + if m.scrollPos > 0 { + m.scrollPos-- + } + } else if m.cursor > 0 { + m.cursor-- + } + case keyEnter: + if len(m.states) > 0 { + m.showDetail = !m.showDetail + m.scrollPos = 0 + } + case keyEsc: + m.showDetail = false + m.scrollPos = 0 + } + } + return m, nil +} + +func (m activeModel) view(width, height int) string { + if m.err != nil { + return errorStyle.Render(fmt.Sprintf("Error loading active sessions: %v", m.err)) + } + if len(m.states) == 0 { + return dimStyle.Render(" No active sessions") + } + + if m.showDetail && m.cursor < len(m.states) { + return m.renderDetail(width, height) + } + + return m.renderList(width, height) +} + +func (m activeModel) renderList(_ int, height int) string { + var lines []string + + lines = append(lines, fmt.Sprintf(" %s (%d)", + titleStyle.Render("Active Sessions"), + len(m.states))) + lines = append(lines, "") + + // Table header + header := fmt.Sprintf(" %-9s %-18s %-14s %-12s %-12s %s", + labelStyle.Render("ID"), + labelStyle.Render("Phase"), + labelStyle.Render("Agent"), + labelStyle.Render("Started"), + labelStyle.Render("Last Active"), + labelStyle.Render("Prompt"), + ) + lines = append(lines, header) + lines = append(lines, " "+dimStyle.Render(strings.Repeat("─", 80))) + + for i, s := range m.states { + shortID := s.SessionID + if len(shortID) > 7 { + shortID = shortID[:7] + } + + phase := renderPhase(s.Phase) + + agentLabel := string(s.AgentType) + if agentLabel == "" { + agentLabel = "(unknown)" + } + agentLabel = truncate(agentLabel, 12) + + started := timeAgo(s.StartedAt) + + lastActive := "-" + if s.LastInteractionTime != nil { + lastActive = timeAgo(*s.LastInteractionTime) + } + + prompt := truncate(s.FirstPrompt, 30) + if prompt == "" { + prompt = dimStyle.Render("-") + } + + line := fmt.Sprintf(" %-9s %-18s %-14s %-12s %-12s %s", + shortID, phase, agentLabel, started, lastActive, prompt) + + if i == m.cursor { + line = selectedItemStyle.Render(line) + } + + lines = append(lines, line) + } + + // Scroll data lines, keeping header pinned + headerLines := 4 // title + blank + header + separator + maxVisible := height - headerLines + if maxVisible < 1 { + maxVisible = 1 + } + + visibleStart := 0 + if m.cursor-visibleStart >= maxVisible { + visibleStart = m.cursor - maxVisible + 1 + } + + result := lines[:headerLines] + dataLines := lines[headerLines:] + end := visibleStart + maxVisible + if end > len(dataLines) { + end = len(dataLines) + } + if visibleStart < len(dataLines) { + result = append(result, dataLines[visibleStart:end]...) + } + + return strings.Join(result, "\n") +} + +func (m activeModel) renderDetail(_ int, height int) string { + s := m.states[m.cursor] + + var lines []string + lines = append(lines, titleStyle.Render("Session Detail")) + lines = append(lines, "") + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Session ID:"), valueStyle.Render(s.SessionID))) + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Phase:"), renderPhase(s.Phase))) + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Agent:"), valueStyle.Render(string(s.AgentType)))) + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Started:"), valueStyle.Render(s.StartedAt.Format("2006-01-02 15:04:05")))) + + if s.LastInteractionTime != nil { + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Last Active:"), valueStyle.Render(s.LastInteractionTime.Format("2006-01-02 15:04:05")))) + } + + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Base Commit:"), valueStyle.Render(truncate(s.BaseCommit, 12)))) + lines = append(lines, fmt.Sprintf(" %s %d", labelStyle.Render("Step Count:"), s.StepCount)) + + if s.WorktreePath != "" { + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Worktree:"), valueStyle.Render(s.WorktreePath))) + } + + if s.FirstPrompt != "" { + lines = append(lines, "") + lines = append(lines, labelStyle.Render(" First Prompt:")) + lines = append(lines, " "+valueStyle.Render(s.FirstPrompt)) + } + + if s.TokenUsage != nil { + lines = append(lines, "") + lines = append(lines, labelStyle.Render(" Token Usage:")) + lines = append(lines, fmt.Sprintf(" Input: %d", s.TokenUsage.InputTokens)) + lines = append(lines, fmt.Sprintf(" Output: %d", s.TokenUsage.OutputTokens)) + } + + if len(s.FilesTouched) > 0 { + lines = append(lines, "") + lines = append(lines, fmt.Sprintf(" %s (%d files)", labelStyle.Render("Files Touched:"), len(s.FilesTouched))) + for _, f := range s.FilesTouched { + lines = append(lines, " "+dimStyle.Render(f)) + } + } + + lines = append(lines, "") + lines = append(lines, dimStyle.Render(" Press Esc to go back")) + + // Apply scroll + start := m.scrollPos + end := start + height + if end > len(lines) { + end = len(lines) + } + if start >= len(lines) { + start = max(0, len(lines)-1) + } + + return strings.Join(lines[start:end], "\n") +} + +func renderPhase(p session.Phase) string { + switch p { + case session.PhaseActive: + return activePhaseStyle.Render("ACTIVE") + case session.PhaseIdle: + return idlePhaseStyle.Render("IDLE") + case session.PhaseEnded: + return endedPhaseStyle.Render("ENDED") + default: + return idlePhaseStyle.Render("IDLE") + } +} diff --git a/cmd/entire/cli/dashboard/checkpoints_tab.go b/cmd/entire/cli/dashboard/checkpoints_tab.go new file mode 100644 index 000000000..177b50982 --- /dev/null +++ b/cmd/entire/cli/dashboard/checkpoints_tab.go @@ -0,0 +1,345 @@ +package dashboard + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutating setters, value receivers for update/view +type checkpointsModel struct { + points []strategy.RewindPoint + filtered []strategy.RewindPoint + err error + cursor int + scrollPos int + width int + height int + showDetail bool + filtering bool + filter string + confirming bool // rewind confirmation dialog +} + +func newCheckpointsModel() checkpointsModel { + return checkpointsModel{} +} + +func (m *checkpointsModel) setSize(width, height int) { + m.width = width + m.height = height +} + +func (m *checkpointsModel) setCheckpoints(points []strategy.RewindPoint) { + m.points = points + m.applyFilter() +} + +func (m *checkpointsModel) applyFilter() { + if m.filter == "" { + m.filtered = m.points + return + } + lower := strings.ToLower(m.filter) + var filtered []strategy.RewindPoint + for _, p := range m.points { + if strings.Contains(strings.ToLower(p.ID), lower) || + strings.Contains(strings.ToLower(p.Message), lower) || + strings.Contains(strings.ToLower(p.SessionPrompt), lower) || + strings.Contains(strings.ToLower(p.CheckpointID.String()), lower) { + filtered = append(filtered, p) + } + } + m.filtered = filtered + if m.cursor >= len(m.filtered) { + m.cursor = max(0, len(m.filtered)-1) + } +} + +func (m checkpointsModel) update(msg tea.Msg) (checkpointsModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + // Handle confirmation dialog + if m.confirming { + switch keyMsg.String() { + case keyY, keyYUpper: + m.confirming = false + if m.cursor < len(m.filtered) { + point := m.filtered[m.cursor] + return m, func() tea.Msg { + return rewindRequestMsg{pointID: point.ID} + } + } + case keyN, keyNUpper, keyEsc: + m.confirming = false + } + return m, nil + } + + // Handle filter input + if m.filtering { + switch keyMsg.String() { + case keyEsc: + m.filtering = false + m.filter = "" + m.applyFilter() + case keyEnter: + m.filtering = false + case keyBackspace: + runes := []rune(m.filter) + if len(runes) > 0 { + m.filter = string(runes[:len(runes)-1]) + m.applyFilter() + } + default: + r := []rune(keyMsg.String()) + if len(r) == 1 && r[0] >= 32 { + m.filter += keyMsg.String() + m.applyFilter() + } + } + return m, nil + } + + switch keyMsg.String() { + case keyJ, keyDown: + if m.showDetail { + m.scrollPos++ + } else if m.cursor < len(m.filtered)-1 { + m.cursor++ + } + case keyK, keyUp: + if m.showDetail { + if m.scrollPos > 0 { + m.scrollPos-- + } + } else if m.cursor > 0 { + m.cursor-- + } + case keyEnter: + if len(m.filtered) > 0 { + m.showDetail = !m.showDetail + m.scrollPos = 0 + } + case keyEsc: + if m.showDetail { + m.showDetail = false + m.scrollPos = 0 + } else if m.filter != "" { + m.filter = "" + m.applyFilter() + } + case keyFilter: + m.filtering = true + case keyR: + if len(m.filtered) > 0 && !m.showDetail { + m.confirming = true + } + } + + return m, nil +} + +func (m checkpointsModel) view(width, height int) string { + if m.err != nil { + return errorStyle.Render(fmt.Sprintf("Error loading checkpoints: %v", m.err)) + } + + if m.confirming && m.cursor < len(m.filtered) { + return m.renderConfirmation(width, height) + } + + if m.showDetail && m.cursor < len(m.filtered) { + return m.renderDetail(width, height) + } + + return m.renderList(width, height) +} + +func (m checkpointsModel) renderList(_ int, height int) string { + var lines []string + + // Header + headerText := fmt.Sprintf("Checkpoints (%d)", len(m.filtered)) + if m.filter != "" { + headerText += " filter: " + m.filter + } + if m.filtering { + headerText += dimStyle.Render(" type to filter, Enter to confirm, Esc to cancel") + } + lines = append(lines, " "+titleStyle.Render(headerText)) + lines = append(lines, "") + + if len(m.filtered) == 0 { + lines = append(lines, " "+dimStyle.Render("No checkpoints found")) + return strings.Join(lines, "\n") + } + + for i, p := range m.filtered { + cpType := cpTypeSession + if p.IsTaskCheckpoint { + cpType = cpTypeTask + } + if p.IsLogsOnly { + cpType = cpTypeCommitted + } + + cpID := p.CheckpointID.String() + if cpID == "" { + cpID = truncate(p.ID, 12) + } + + prompt := truncate(p.SessionPrompt, 40) + if prompt == "" { + prompt = truncate(p.Message, 40) + } + + agentStr := string(p.Agent) + if agentStr == "" { + agentStr = "-" + } + + line := fmt.Sprintf(" %-12s %-10s %-14s %-10s %s", + cpID, + dimStyle.Render("["+cpType+"]"), + agentStr, + dimStyle.Render(timeAgo(p.Date)), + prompt, + ) + + if i == m.cursor { + line = selectedItemStyle.Render(line) + } + + lines = append(lines, line) + } + + // Scroll + visibleStart := 0 + headerLines := 2 + maxVisible := height - headerLines + if maxVisible < 1 { + maxVisible = 1 + } + + if m.cursor-visibleStart >= maxVisible { + visibleStart = m.cursor - maxVisible + 1 + } + + result := lines[:headerLines] + dataLines := lines[headerLines:] + end := visibleStart + maxVisible + if end > len(dataLines) { + end = len(dataLines) + } + if visibleStart < len(dataLines) { + result = append(result, dataLines[visibleStart:end]...) + } + + return strings.Join(result, "\n") +} + +func (m checkpointsModel) renderDetail(_ int, height int) string { + p := m.filtered[m.cursor] + + var lines []string + lines = append(lines, titleStyle.Render("Checkpoint Detail")) + lines = append(lines, "") + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("ID:"), valueStyle.Render(p.ID))) + + cpID := p.CheckpointID.String() + if cpID != "" { + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Checkpoint ID:"), valueStyle.Render(cpID))) + } + + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Date:"), valueStyle.Render(p.Date.Format("2006-01-02 15:04:05")))) + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Message:"), valueStyle.Render(p.Message))) + + if string(p.Agent) != "" { + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Agent:"), valueStyle.Render(string(p.Agent)))) + } + + cpType := "Session checkpoint" + if p.IsTaskCheckpoint { + cpType = "Task checkpoint" + } + if p.IsLogsOnly { + cpType = "Committed (logs only)" + } + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Type:"), valueStyle.Render(cpType))) + + if p.SessionID != "" { + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Session:"), valueStyle.Render(p.SessionID))) + } + + if p.SessionPrompt != "" { + lines = append(lines, "") + lines = append(lines, labelStyle.Render(" Prompt:")) + lines = append(lines, " "+valueStyle.Render(p.SessionPrompt)) + } + + if p.ToolUseID != "" { + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Tool Use ID:"), valueStyle.Render(p.ToolUseID))) + } + + if p.SessionCount > 1 { + lines = append(lines, fmt.Sprintf(" %s %d", labelStyle.Render("Sessions:"), p.SessionCount)) + for i, sid := range p.SessionIDs { + prompt := "" + if i < len(p.SessionPrompts) { + prompt = truncate(p.SessionPrompts[i], 40) + } + lines = append(lines, fmt.Sprintf(" %s %s", dimStyle.Render(truncate(sid, 20)), prompt)) + } + } + + lines = append(lines, "") + lines = append(lines, dimStyle.Render(" Press Esc to go back")) + + // Apply scroll + start := m.scrollPos + end := start + height + if end > len(lines) { + end = len(lines) + } + if start >= len(lines) { + start = max(0, len(lines)-1) + } + + return strings.Join(lines[start:end], "\n") +} + +func (m checkpointsModel) renderConfirmation(_ int, _ int) string { + p := m.filtered[m.cursor] + + var lines []string + lines = append(lines, "") + lines = append(lines, confirmStyle.Render(" Rewind to this checkpoint?")) + lines = append(lines, "") + + cpID := p.CheckpointID.String() + if cpID == "" { + cpID = truncate(p.ID, 20) + } + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Checkpoint:"), valueStyle.Render(cpID))) + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Date:"), valueStyle.Render(p.Date.Format("2006-01-02 15:04:05")))) + if p.SessionPrompt != "" { + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Prompt:"), valueStyle.Render(truncate(p.SessionPrompt, 50)))) + } + + if p.IsLogsOnly { + lines = append(lines, "") + lines = append(lines, " "+idlePhaseStyle.Render("This is a logs-only checkpoint. Only session logs will be restored.")) + } + + lines = append(lines, "") + lines = append(lines, " "+confirmStyle.Render("Press y to confirm, n or Esc to cancel")) + + return strings.Join(lines, "\n") +} diff --git a/cmd/entire/cli/dashboard/dashboard.go b/cmd/entire/cli/dashboard/dashboard.go new file mode 100644 index 000000000..983a8bcee --- /dev/null +++ b/cmd/entire/cli/dashboard/dashboard.go @@ -0,0 +1,316 @@ +package dashboard + +import ( + "errors" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + tabSessions = 0 + tabCheckpoints = 1 + tabActive = 2 + tabSettings = 3 +) + +// Key string constants shared across tab models. +const ( + keyDown = "down" + keyUp = "up" + keyEnter = "enter" + keyEsc = "esc" + keyBackspace = "backspace" + keyFilter = "/" + keyJ = "j" + keyK = "k" + keyR = "r" + keyY = "y" + keyYUpper = "Y" + keyN = "n" + keyNUpper = "N" +) + +// Checkpoint type labels. +const ( + cpTypeSession = "session" + cpTypeTask = "task" + cpTypeCommitted = "committed" +) + +var tabNames = []string{"Sessions", "Checkpoints", "Active", "Settings"} + +// RewindRequest is returned by Run when the user requests a rewind from the TUI. +type RewindRequest struct { + PointID string +} + +// Model is the root bubbletea model for the dashboard. +type Model struct { + activeTab int + sessions sessionsModel + checkpoints checkpointsModel + active activeModel + settings settingsModel + width int + height int + showHelp bool + err error + rewindReq *RewindRequest + quitting bool + dataLoaded map[int]bool // tracks whether each tab's data has loaded +} + +func newModel() Model { + return Model{ + activeTab: tabSessions, + sessions: newSessionsModel(), + checkpoints: newCheckpointsModel(), + active: newActiveModel(), + settings: newSettingsModel(), + dataLoaded: make(map[int]bool), + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + loadSessionsCmd, + loadCheckpointsCmd, + loadActiveSessionsCmd, + loadSettingsCmd, + ) +} + +//nolint:ireturn // required by bubbletea.Model interface +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + contentHeight := m.contentHeight() + m.sessions.setSize(m.width, contentHeight) + m.checkpoints.setSize(m.width, contentHeight) + m.active.setSize(m.width, contentHeight) + m.settings.setSize(m.width, contentHeight) + return m, nil + + case tea.KeyMsg: + // If a tab is in filter/input mode, delegate to it first + if m.isTabCapturingInput() { + return m.updateActiveTab(msg) + } + + switch { + case msg.String() == "q" || msg.String() == "ctrl+c": + m.quitting = true + return m, tea.Quit + case msg.String() == "?": + m.showHelp = !m.showHelp + return m, nil + case msg.String() == "tab": + m.activeTab = (m.activeTab + 1) % len(tabNames) + return m, nil + case msg.String() == "shift+tab": + m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames) + return m, nil + } + + return m.updateActiveTab(msg) + + case sessionsMsg: + m.dataLoaded[tabSessions] = true + m.sessions.setSessions(msg.sessions) + if msg.err != nil { + m.sessions.err = msg.err + } + return m, nil + + case checkpointsMsg: + m.dataLoaded[tabCheckpoints] = true + m.checkpoints.setCheckpoints(msg.points) + if msg.err != nil { + m.checkpoints.err = msg.err + } + return m, nil + + case activeSessionsMsg: + m.dataLoaded[tabActive] = true + m.active.setSessions(msg.states) + if msg.err != nil { + m.active.err = msg.err + } + return m, nil + + case settingsDataMsg: + m.dataLoaded[tabSettings] = true + m.settings.setData(msg) + return m, nil + + case rewindRequestMsg: + m.rewindReq = &RewindRequest{PointID: msg.pointID} + m.quitting = true + return m, tea.Quit + } + + return m.updateActiveTab(msg) +} + +//nolint:ireturn // required by bubbletea update pattern +func (m Model) updateActiveTab(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch m.activeTab { + case tabSessions: + m.sessions, cmd = m.sessions.update(msg) + case tabCheckpoints: + m.checkpoints, cmd = m.checkpoints.update(msg) + case tabActive: + m.active, cmd = m.active.update(msg) + case tabSettings: + m.settings, cmd = m.settings.update(msg) + } + return m, cmd +} + +func (m Model) isTabCapturingInput() bool { + switch m.activeTab { + case tabSessions: + return m.sessions.filtering + case tabCheckpoints: + return m.checkpoints.filtering || m.checkpoints.confirming + } + return false +} + +func (m Model) View() string { + if m.quitting { + return "" + } + + var b strings.Builder + + // Tab bar + b.WriteString(m.renderTabBar()) + b.WriteString("\n") + + // Content + contentHeight := m.contentHeight() + content := m.renderActiveTab(contentHeight) + b.WriteString(content) + + // Help / status bar + b.WriteString("\n") + b.WriteString(m.renderStatusBar()) + + return b.String() +} + +func (m Model) renderTabBar() string { + var tabs []string + for i, name := range tabNames { + if i == m.activeTab { + tabs = append(tabs, activeTabStyle.Render("["+name+"]")) + } else { + tabs = append(tabs, inactiveTabStyle.Render(" "+name+" ")) + } + } + bar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + separator := strings.Repeat("─", max(0, m.width-lipgloss.Width(bar))) + return bar + tabGapStyle.Render(separator) +} + +func (m Model) renderActiveTab(height int) string { + switch m.activeTab { + case tabSessions: + if !m.dataLoaded[tabSessions] { + return renderLoading("sessions", height) + } + return m.sessions.view(m.width, height) + case tabCheckpoints: + if !m.dataLoaded[tabCheckpoints] { + return renderLoading("checkpoints", height) + } + return m.checkpoints.view(m.width, height) + case tabActive: + if !m.dataLoaded[tabActive] { + return renderLoading("active sessions", height) + } + return m.active.view(m.width, height) + case tabSettings: + if !m.dataLoaded[tabSettings] { + return renderLoading("settings", height) + } + return m.settings.view(m.width, height) + } + return "" +} + +func (m Model) renderStatusBar() string { + if m.showHelp { + return m.renderFullHelp() + } + hint := "Tab: switch | j/k: navigate | Enter: detail | ?: help | q: quit" + if m.activeTab == tabCheckpoints { + hint = "Tab: switch | j/k: navigate | Enter: detail | r: rewind | /: filter | ?: help | q: quit" + } + if m.activeTab == tabSessions { + hint = "Tab: switch | j/k: navigate | Enter: detail | /: filter | ?: help | q: quit" + } + return statusBarStyle.Render(hint) +} + +func (m Model) renderFullHelp() string { + help := []string{ + " Tab/Shift+Tab Switch tabs", + " j/k, Up/Down Navigate items", + " Enter View detail / expand", + " Esc Close detail / clear filter", + " / Search / filter", + " r Rewind (Checkpoints tab)", + " ? Toggle this help", + " q, Ctrl+C Quit", + } + return helpStyle.Render(strings.Join(help, "\n")) +} + +func (m Model) contentHeight() int { + // Tab bar (1) + separator (0, part of tab bar) + status bar (1) + margins + overhead := 3 + if m.showHelp { + overhead = 10 // help takes more space + } + h := m.height - overhead + if h < 5 { + h = 5 + } + return h +} + +func renderLoading(what string, height int) string { + msg := dimStyle.Render(fmt.Sprintf("Loading %s...", what)) + // Center vertically + padding := height / 2 + return strings.Repeat("\n", padding) + " " + msg +} + +// Run starts the dashboard TUI. Returns a RewindRequest if the user wants to rewind. +func Run() (*RewindRequest, error) { + m := newModel() + p := tea.NewProgram(m, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return nil, fmt.Errorf("dashboard TUI error: %w", err) + } + + result, ok := finalModel.(Model) + if !ok { + return nil, errors.New("unexpected model type from TUI") + } + if result.err != nil { + return nil, result.err + } + + return result.rewindReq, nil +} diff --git a/cmd/entire/cli/dashboard/data.go b/cmd/entire/cli/dashboard/data.go new file mode 100644 index 000000000..2dc93d0a2 --- /dev/null +++ b/cmd/entire/cli/dashboard/data.go @@ -0,0 +1,122 @@ +package dashboard + +import ( + "context" + "errors" + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// Message types for async data loading. +type sessionsMsg struct { + sessions []strategy.Session + err error +} + +type checkpointsMsg struct { + points []strategy.RewindPoint + err error +} + +type activeSessionsMsg struct { + states []*session.State + err error +} + +type settingsDataMsg struct { + settings *settings.EntireSettings + agents []agent.AgentName + err error +} + +type rewindRequestMsg struct { + pointID string +} + +// Commands that load data asynchronously. + +//nolint:ireturn // required by bubbletea Cmd signature +func loadSessionsCmd() tea.Msg { + sessions, err := strategy.ListSessions() + return sessionsMsg{sessions: sessions, err: err} +} + +//nolint:ireturn // required by bubbletea Cmd signature +func loadCheckpointsCmd() tea.Msg { + strat := getConfiguredStrategy() + if strat == nil { + return checkpointsMsg{err: errors.New("no strategy configured")} + } + points, err := strat.GetRewindPoints(100) + return checkpointsMsg{points: points, err: err} +} + +//nolint:ireturn // required by bubbletea Cmd signature +func loadActiveSessionsCmd() tea.Msg { + store, err := session.NewStateStore() + if err != nil { + return activeSessionsMsg{err: err} + } + states, err := store.List(context.Background()) + return activeSessionsMsg{states: states, err: err} +} + +//nolint:ireturn // required by bubbletea Cmd signature +func loadSettingsCmd() tea.Msg { + s, err := settings.Load() + if err != nil { + return settingsDataMsg{err: err} + } + agents := agent.List() + return settingsDataMsg{settings: s, agents: agents} +} + +// getConfiguredStrategy loads settings and returns the configured strategy. +func getConfiguredStrategy() strategy.Strategy { //nolint:ireturn // factory pattern + s, err := settings.Load() + if err != nil { + return strategy.Default() + } + strat, err := strategy.Get(s.Strategy) + if err != nil { + return strategy.Default() + } + return strat +} + +// timeAgo formats a time as a human-readable relative duration. +func timeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + m := int(d.Minutes()) + return fmt.Sprintf("%dm ago", m) + case d < 24*time.Hour: + h := int(d.Hours()) + return fmt.Sprintf("%dh ago", h) + default: + days := int(d.Hours() / 24) + return fmt.Sprintf("%dd ago", days) + } +} + +// truncate shortens a string with an ellipsis if it exceeds maxLen. +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + if maxLen < 3 { + return string(runes[:maxLen]) + } + return string(runes[:maxLen-3]) + "..." +} diff --git a/cmd/entire/cli/dashboard/sessions_tab.go b/cmd/entire/cli/dashboard/sessions_tab.go new file mode 100644 index 000000000..5093e3bcf --- /dev/null +++ b/cmd/entire/cli/dashboard/sessions_tab.go @@ -0,0 +1,258 @@ +package dashboard + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutating setters, value receivers for update/view +type sessionsModel struct { + sessions []strategy.Session + filtered []strategy.Session + err error + cursor int + scrollPos int + width int + height int + showDetail bool + filtering bool + filter string +} + +func newSessionsModel() sessionsModel { + return sessionsModel{} +} + +func (m *sessionsModel) setSize(width, height int) { + m.width = width + m.height = height +} + +func (m *sessionsModel) setSessions(sessions []strategy.Session) { + m.sessions = sessions + m.applyFilter() +} + +func (m *sessionsModel) applyFilter() { + if m.filter == "" { + m.filtered = m.sessions + return + } + lower := strings.ToLower(m.filter) + var filtered []strategy.Session + for _, s := range m.sessions { + if strings.Contains(strings.ToLower(s.ID), lower) || + strings.Contains(strings.ToLower(s.Description), lower) || + strings.Contains(strings.ToLower(s.Strategy), lower) { + filtered = append(filtered, s) + } + } + m.filtered = filtered + if m.cursor >= len(m.filtered) { + m.cursor = max(0, len(m.filtered)-1) + } +} + +func (m sessionsModel) update(msg tea.Msg) (sessionsModel, tea.Cmd) { //nolint:unparam // tea.Cmd needed for consistent tab interface + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if m.filtering { + switch keyMsg.String() { + case keyEsc: + m.filtering = false + m.filter = "" + m.applyFilter() + case keyEnter: + m.filtering = false + case keyBackspace: + runes := []rune(m.filter) + if len(runes) > 0 { + m.filter = string(runes[:len(runes)-1]) + m.applyFilter() + } + default: + r := []rune(keyMsg.String()) + if len(r) == 1 && r[0] >= 32 { + m.filter += keyMsg.String() + m.applyFilter() + } + } + return m, nil + } + + switch keyMsg.String() { + case keyJ, keyDown: + if m.showDetail { + m.scrollPos++ + } else if m.cursor < len(m.filtered)-1 { + m.cursor++ + } + case keyK, keyUp: + if m.showDetail { + if m.scrollPos > 0 { + m.scrollPos-- + } + } else if m.cursor > 0 { + m.cursor-- + } + case keyEnter: + if len(m.filtered) > 0 { + m.showDetail = !m.showDetail + m.scrollPos = 0 + } + case keyEsc: + if m.showDetail { + m.showDetail = false + m.scrollPos = 0 + } else if m.filter != "" { + m.filter = "" + m.applyFilter() + } + case keyFilter: + m.filtering = true + } + + return m, nil +} + +func (m sessionsModel) view(width, height int) string { + if m.err != nil { + return errorStyle.Render(fmt.Sprintf("Error loading sessions: %v", m.err)) + } + + if m.showDetail && m.cursor < len(m.filtered) { + return m.renderDetail(width, height) + } + + return m.renderList(width, height) +} + +func (m sessionsModel) renderList(_ int, height int) string { + var lines []string + + // Header + headerText := fmt.Sprintf("Sessions (%d)", len(m.filtered)) + if m.filter != "" { + headerText += " filter: " + m.filter + } + if m.filtering { + headerText += dimStyle.Render(" type to filter, Enter to confirm, Esc to cancel") + } + lines = append(lines, " "+titleStyle.Render(headerText)) + lines = append(lines, "") + + if len(m.filtered) == 0 { + lines = append(lines, " "+dimStyle.Render("No sessions found")) + return strings.Join(lines, "\n") + } + + for i, s := range m.filtered { + shortID := s.ID + if len(shortID) > 12 { + shortID = shortID[:12] + } + + desc := s.Description + if desc == "" || desc == strategy.NoDescription { + desc = dimStyle.Render("(no description)") + } else { + desc = truncate(desc, 50) + } + + age := timeAgo(s.StartTime) + cpCount := len(s.Checkpoints) + stratName := s.Strategy + if stratName == "" { + stratName = "-" + } + + line := fmt.Sprintf(" %-12s %s %s %s %d cp", + shortID, desc, dimStyle.Render(stratName), dimStyle.Render(age), cpCount) + + if i == m.cursor { + line = selectedItemStyle.Render(line) + } + + lines = append(lines, line) + } + + // Scroll if needed + visibleStart := 0 + headerLines := 2 + maxVisible := height - headerLines + if maxVisible < 1 { + maxVisible = 1 + } + + if m.cursor-visibleStart >= maxVisible { + visibleStart = m.cursor - maxVisible + 1 + } + + result := lines[:headerLines] // always show header + dataLines := lines[headerLines:] + end := visibleStart + maxVisible + if end > len(dataLines) { + end = len(dataLines) + } + if visibleStart < len(dataLines) { + result = append(result, dataLines[visibleStart:end]...) + } + + return strings.Join(result, "\n") +} + +func (m sessionsModel) renderDetail(_ int, height int) string { + s := m.filtered[m.cursor] + + var lines []string + lines = append(lines, titleStyle.Render("Session Detail")) + lines = append(lines, "") + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("ID:"), valueStyle.Render(s.ID))) + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Description:"), valueStyle.Render(s.Description))) + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Strategy:"), valueStyle.Render(s.Strategy))) + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Started:"), valueStyle.Render(s.StartTime.Format("2006-01-02 15:04:05")))) + lines = append(lines, "") + + lines = append(lines, fmt.Sprintf(" %s (%d)", labelStyle.Render("Checkpoints:"), len(s.Checkpoints))) + lines = append(lines, "") + + for _, cp := range s.Checkpoints { + cpType := cpTypeSession + if cp.IsTaskCheckpoint { + cpType = cpTypeTask + } + cpID := cp.CheckpointID.String() + if cpID == "" { + cpID = "(none)" + } + + lines = append(lines, fmt.Sprintf(" %s %s %s %s", + dimStyle.Render(cpID), + dimStyle.Render("["+cpType+"]"), + cp.Message, + dimStyle.Render(timeAgo(cp.Timestamp)), + )) + } + + lines = append(lines, "") + lines = append(lines, dimStyle.Render(" Press Esc to go back")) + + // Apply scroll + start := m.scrollPos + end := start + height + if end > len(lines) { + end = len(lines) + } + if start >= len(lines) { + start = max(0, len(lines)-1) + } + + return strings.Join(lines[start:end], "\n") +} diff --git a/cmd/entire/cli/dashboard/settings_tab.go b/cmd/entire/cli/dashboard/settings_tab.go new file mode 100644 index 000000000..21d9d1185 --- /dev/null +++ b/cmd/entire/cli/dashboard/settings_tab.go @@ -0,0 +1,171 @@ +package dashboard + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +//nolint:recvcheck // bubbletea pattern: pointer receivers for mutating setters, value receivers for update/view +type settingsModel struct { + data *settings.EntireSettings + agents []agent.AgentName + err error + scrollPos int + height int + lines []string +} + +func newSettingsModel() settingsModel { + return settingsModel{} +} + +func (m *settingsModel) setSize(_ int, height int) { + m.height = height +} + +func (m *settingsModel) setData(msg settingsDataMsg) { + m.data = msg.settings + m.agents = msg.agents + m.err = msg.err + m.buildLines() +} + +func (m *settingsModel) buildLines() { + if m.data == nil { + return + } + + var lines []string + s := m.data + + lines = append(lines, titleStyle.Render("Configuration")) + lines = append(lines, "") + + // Enabled/Disabled + enabledStr := activePhaseStyle.Render("Enabled") + if !s.Enabled { + enabledStr = endedPhaseStyle.Render("Disabled") + } + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Status:"), enabledStr)) + + // Strategy + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Strategy:"), valueStyle.Render(s.Strategy))) + + // Log level + logLevel := s.LogLevel + if logLevel == "" { + logLevel = "info (default)" + } + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Log Level:"), valueStyle.Render(logLevel))) + + // Telemetry + telemetryStr := dimStyle.Render("not configured") + if s.Telemetry != nil { + if *s.Telemetry { + telemetryStr = activePhaseStyle.Render("opted in") + } else { + telemetryStr = endedPhaseStyle.Render("opted out") + } + } + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Telemetry:"), telemetryStr)) + + // Summarize + summarize := "disabled" + if s.IsSummarizeEnabled() { + summarize = "enabled" + } + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Summarize:"), valueStyle.Render(summarize))) + + // Push sessions + pushSessions := "enabled" + if s.IsPushSessionsDisabled() { + pushSessions = "disabled" + } + lines = append(lines, fmt.Sprintf(" %s %s", labelStyle.Render("Push Sessions:"), valueStyle.Render(pushSessions))) + + lines = append(lines, "") + lines = append(lines, titleStyle.Render("Installed Agents")) + lines = append(lines, "") + + if len(m.agents) == 0 { + lines = append(lines, " "+dimStyle.Render("No agents registered")) + } else { + for _, name := range m.agents { + ag, err := agent.Get(name) + if err != nil { + lines = append(lines, fmt.Sprintf(" %s %s", dimStyle.Render("-"), valueStyle.Render(string(name)))) + continue + } + + hooksStr := dimStyle.Render("no hooks") + if hs, ok := ag.(agent.HookSupport); ok { + if hs.AreHooksInstalled() { + hooksStr = activePhaseStyle.Render("hooks installed") + } else { + hooksStr = idlePhaseStyle.Render("hooks not installed") + } + } + + lines = append(lines, fmt.Sprintf(" %s %s %s", + labelStyle.Render(string(ag.Type())), + dimStyle.Render("("+string(name)+")"), + hooksStr, + )) + } + } + + // Strategy options + if len(s.StrategyOptions) > 0 { + lines = append(lines, "") + lines = append(lines, titleStyle.Render("Strategy Options")) + lines = append(lines, "") + for k, v := range s.StrategyOptions { + lines = append(lines, fmt.Sprintf(" %s %v", labelStyle.Render(k+":"), v)) + } + } + + m.lines = lines +} + +func (m settingsModel) update(msg tea.Msg) (settingsModel, tea.Cmd) { //nolint:unparam // tea.Cmd needed for consistent tab interface + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case keyJ, keyDown: + if m.scrollPos < len(m.lines)-m.height { + m.scrollPos++ + } + case keyK, keyUp: + if m.scrollPos > 0 { + m.scrollPos-- + } + } + } + return m, nil +} + +func (m settingsModel) view(_ int, height int) string { + if m.err != nil { + return errorStyle.Render(fmt.Sprintf("Error loading settings: %v", m.err)) + } + if m.data == nil { + return dimStyle.Render(" No settings data available") + } + + // Apply scroll + start := m.scrollPos + end := start + height + if end > len(m.lines) { + end = len(m.lines) + } + if start > len(m.lines) { + start = len(m.lines) + } + + visible := m.lines[start:end] + return strings.Join(visible, "\n") +} diff --git a/cmd/entire/cli/dashboard/styles.go b/cmd/entire/cli/dashboard/styles.go new file mode 100644 index 000000000..2b50486c0 --- /dev/null +++ b/cmd/entire/cli/dashboard/styles.go @@ -0,0 +1,80 @@ +// Package dashboard provides an interactive TUI for browsing sessions, +// checkpoints, and settings. +package dashboard + +import "github.com/charmbracelet/lipgloss" + +// Dracula palette colors (consistent with entireTheme in cli package). +const ( + colorPurple = "#BD93F9" + colorComment = "#6272A4" + colorGreen = "#50FA7B" + colorYellow = "#F1FA8C" + colorRed = "#FF5555" + colorCyan = "#8BE9FD" + colorOrange = "#FFB86C" + colorPink = "#FF79C6" + colorFg = "#F8F8F2" + colorBg = "#282A36" + colorCurLine = "#44475A" +) + +// Tab styles. +var ( + activeTabStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorPurple)). + Padding(0, 2) + + inactiveTabStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorComment)). + Padding(0, 2) + + tabGapStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorComment)) +) + +// Content styles. +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorPurple)) + + selectedItemStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorFg)). + Background(lipgloss.Color(colorCurLine)) + + activePhaseStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorGreen)) + + idlePhaseStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorYellow)) + + endedPhaseStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorRed)) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorComment)) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorRed)) + + labelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorCyan)) + + valueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorFg)) + + dimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorComment)) + + confirmStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorOrange)) +) + +// statusBar renders the bottom help bar. +var statusBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorComment)). + Padding(0, 1) diff --git a/cmd/entire/cli/dashboard_cmd.go b/cmd/entire/cli/dashboard_cmd.go new file mode 100644 index 000000000..702d16303 --- /dev/null +++ b/cmd/entire/cli/dashboard_cmd.go @@ -0,0 +1,126 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/dashboard" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/spf13/cobra" +) + +func newDashboardCmd() *cobra.Command { + return &cobra.Command{ + Use: "dashboard", + Short: "Open interactive session dashboard", + Long: `Interactive TUI dashboard for browsing sessions, checkpoints, +active sessions, and settings. + +Navigate with Tab/Shift+Tab between tabs, j/k or arrow keys to move, +Enter to view details, and q to quit. + +In the Checkpoints tab, press r to rewind to a selected checkpoint.`, + RunE: func(cmd *cobra.Command, _ []string) error { + // Check if we're in a git repository + if _, err := paths.RepoRoot(); err != nil { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository. Please run from within a git repository.") + return NewSilentError(errors.New("not a git repository")) + } + + // Check if Entire is disabled + if checkDisabledGuard(cmd.OutOrStdout()) { + return nil + } + + // Accessible mode uses text-based menu + if IsAccessibleMode() { + return dashboard.RunAccessible(cmd.OutOrStdout()) + } + + // Run TUI dashboard + rewindReq, err := dashboard.Run() + if err != nil { + return fmt.Errorf("dashboard error: %w", err) + } + + // Handle rewind request from dashboard + if rewindReq != nil { + return performRewindFromDashboard(cmd, rewindReq.PointID) + } + + return nil + }, + } +} + +// performRewindFromDashboard executes a rewind after the dashboard TUI exits. +func performRewindFromDashboard(cmd *cobra.Command, pointID string) error { + strat := GetStrategy() + + // Check if rewind is possible + canRewind, reason, err := strat.CanRewind() + if err != nil { + return fmt.Errorf("failed to check rewind status: %w", err) + } + if !canRewind { + fmt.Fprintln(cmd.OutOrStdout(), reason) + return nil + } + + // Find the matching rewind point + points, err := strat.GetRewindPoints(100) + if err != nil { + return fmt.Errorf("failed to get rewind points: %w", err) + } + + var target *strategy.RewindPoint + for i := range points { + if points[i].ID == pointID { + target = &points[i] + break + } + } + + if target == nil { + return fmt.Errorf("rewind point %s not found", pointID) + } + + // Preview rewind to show warnings about files that will be deleted + preview, previewErr := strat.PreviewRewind(*target) + if previewErr == nil && preview != nil && len(preview.FilesToDelete) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "\nWarning: The following untracked files will be DELETED:\n") + for _, f := range preview.FilesToDelete { + fmt.Fprintf(cmd.ErrOrStderr(), " - %s\n", f) + } + fmt.Fprintf(cmd.ErrOrStderr(), "\n") + } + + // Perform the rewind + if err := strat.Rewind(*target); err != nil { + return fmt.Errorf("rewind failed: %w", err) + } + + fmt.Fprintln(cmd.OutOrStdout(), "Successfully rewound to checkpoint.") + + // Print resume command if agent info available + if target.Agent != "" { + resumeCmd := formatResumeCommand(target.Agent, target.SessionID) + if resumeCmd != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\nTo resume: %s\n", resumeCmd) + } + } + + return nil +} + +// formatResumeCommand generates the resume command for an agent. +func formatResumeCommand(agentType agent.AgentType, sessionID string) string { + ag, err := agent.GetByAgentType(agentType) + if err != nil { + return "" + } + return ag.FormatResumeCommand(sessionID) +} diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 5fedf6ad4..db7e8117f 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -83,6 +83,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newExplainCmd()) cmd.AddCommand(newDebugCmd()) cmd.AddCommand(newDoctorCmd()) + cmd.AddCommand(newDashboardCmd()) cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index c0843bd00..d2a437a7c 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -84,7 +84,7 @@ func (s *AutoCommitStrategy) getCheckpointStore() (*checkpoint.GitStore, error) // NewAutoCommitStrategy creates a new AutoCommitStrategy instance // -func NewAutoCommitStrategy() Strategy { //nolint:ireturn // factory returns interface by design +func NewAutoCommitStrategy() Strategy { return &AutoCommitStrategy{} } diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 2204db0f2..499732c0b 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -58,7 +58,7 @@ func (s *ManualCommitStrategy) getCheckpointStore() (*checkpoint.GitStore, error // NewManualCommitStrategy creates a new manual-commit strategy instance. // -func NewManualCommitStrategy() Strategy { //nolint:ireturn // factory returns interface by design +func NewManualCommitStrategy() Strategy { return &ManualCommitStrategy{} } @@ -66,7 +66,7 @@ func NewManualCommitStrategy() Strategy { //nolint:ireturn // factory returns in // This legacy constructor delegates to NewManualCommitStrategy. // -func NewShadowStrategy() Strategy { //nolint:ireturn // factory returns interface by design +func NewShadowStrategy() Strategy { return NewManualCommitStrategy() } diff --git a/cmd/entire/cli/strategy/registry.go b/cmd/entire/cli/strategy/registry.go index bdde3069c..5d226590d 100644 --- a/cmd/entire/cli/strategy/registry.go +++ b/cmd/entire/cli/strategy/registry.go @@ -24,7 +24,7 @@ func Register(name string, factory Factory) { // Get retrieves a strategy by name. // Returns an error if the strategy is not registered. -func Get(name string) (Strategy, error) { //nolint:ireturn // registry returns interface by design +func Get(name string) (Strategy, error) { registryMu.RLock() defer registryMu.RUnlock() @@ -61,7 +61,7 @@ const DefaultStrategyName = StrategyNameManualCommit // Default returns the default strategy. // Falls back to returning nil if no strategies are registered. -func Default() Strategy { //nolint:ireturn // registry returns interface by design +func Default() Strategy { s, err := Get(DefaultStrategyName) if err != nil { // Fallback: return the first registered strategy diff --git a/go.mod b/go.mod index 614a8601a..1112ebfb7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/entireio/cli go 1.25.6 require ( + github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/denisbrodbeck/machineid v1.0.1 github.com/go-git/go-git/v5 v5.16.5 @@ -34,9 +36,7 @@ require ( github.com/bodgit/windows v1.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect From 779b1e3a466f8e8eeae389eb22932ba6509e4703 Mon Sep 17 00:00:00 2001 From: yasunogithub Date: Wed, 18 Feb 2026 18:11:27 +0900 Subject: [PATCH 2/2] fix: add Strategy to ireturn allow list and add dashboard tests Add strategy.Strategy to the ireturn allow list in .golangci.yaml, replacing scattered //nolint:ireturn comments on factory functions. Add comprehensive test coverage for the dashboard package and command. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: f3278aa07808 --- .golangci.yaml | 1 + cmd/entire/cli/config.go | 2 +- cmd/entire/cli/dashboard/active_tab_test.go | 149 +++++++ .../cli/dashboard/checkpoints_tab_test.go | 398 ++++++++++++++++++ cmd/entire/cli/dashboard/data.go | 2 +- cmd/entire/cli/dashboard/data_test.go | 60 +++ cmd/entire/cli/dashboard/model_test.go | 276 ++++++++++++ cmd/entire/cli/dashboard/sessions_tab_test.go | 220 ++++++++++ cmd/entire/cli/dashboard/settings_tab_test.go | 119 ++++++ cmd/entire/cli/dashboard/testhelpers_test.go | 103 +++++ cmd/entire/cli/dashboard_cmd_test.go | 57 +++ 11 files changed, 1385 insertions(+), 2 deletions(-) create mode 100644 cmd/entire/cli/dashboard/active_tab_test.go create mode 100644 cmd/entire/cli/dashboard/checkpoints_tab_test.go create mode 100644 cmd/entire/cli/dashboard/data_test.go create mode 100644 cmd/entire/cli/dashboard/model_test.go create mode 100644 cmd/entire/cli/dashboard/sessions_tab_test.go create mode 100644 cmd/entire/cli/dashboard/settings_tab_test.go create mode 100644 cmd/entire/cli/dashboard/testhelpers_test.go create mode 100644 cmd/entire/cli/dashboard_cmd_test.go diff --git a/.golangci.yaml b/.golangci.yaml index f20c1a11b..691a72e55 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -108,6 +108,7 @@ linters: - github.com/go-git/go-git/v6/storage.Storer - github.com/go-git/go-git/v6/plumbing/storer.EncodedObjectIter - github.com/go-git/go-billy/v6.Filesystem + - github.com/entireio/cli/cmd/entire/cli/strategy.Strategy nolintlint: require-explanation: true require-specific: true diff --git a/cmd/entire/cli/config.go b/cmd/entire/cli/config.go index 48246c5be..6eb704cbb 100644 --- a/cmd/entire/cli/config.go +++ b/cmd/entire/cli/config.go @@ -65,7 +65,7 @@ func IsEnabled() (bool, error) { // GetStrategy returns the configured strategy instance. // Falls back to default if the configured strategy is not found. // -//nolint:ireturn // Factory pattern requires returning the interface + func GetStrategy() strategy.Strategy { s, err := settings.Load() if err != nil { diff --git a/cmd/entire/cli/dashboard/active_tab_test.go b/cmd/entire/cli/dashboard/active_tab_test.go new file mode 100644 index 000000000..2d7bc43ba --- /dev/null +++ b/cmd/entire/cli/dashboard/active_tab_test.go @@ -0,0 +1,149 @@ +package dashboard + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" +) + +func TestActiveModel_SetSessionsFiltersEnded(t *testing.T) { + t.Parallel() + + m := newActiveModel() + states := testSessionStates(5, true) // last one is ended + + m.setSessions(states) + + // Should have 4 active sessions (5 total - 1 ended) + assert.Len(t, m.states, 4) +} + +func TestActiveModel_SetSessionsAllActive(t *testing.T) { + t.Parallel() + + m := newActiveModel() + states := testSessionStates(3, false) // none ended + + m.setSessions(states) + assert.Len(t, m.states, 3) +} + +func TestActiveModel_Navigation(t *testing.T) { + t.Parallel() + + t.Run("j_moves_cursor_down", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + m.setSessions(testSessionStates(5, false)) + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 1, m.cursor) + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 2, m.cursor) + }) + + t.Run("k_moves_cursor_up", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + m.setSessions(testSessionStates(5, false)) + m.cursor = 3 + + m, _ = m.update(keyMsg("k")) + assert.Equal(t, 2, m.cursor) + }) + + t.Run("cursor_stays_at_bounds", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + m.setSessions(testSessionStates(3, false)) + + // At bottom + m.cursor = 2 + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 2, m.cursor) + + // At top + m.cursor = 0 + m, _ = m.update(keyMsg("k")) + assert.Equal(t, 0, m.cursor) + }) +} + +func TestActiveModel_DetailToggle(t *testing.T) { + t.Parallel() + + t.Run("enter_opens_detail", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + m.setSessions(testSessionStates(3, false)) + + m, _ = m.update(keyMsg("enter")) + assert.True(t, m.showDetail) + }) + + t.Run("esc_closes_detail", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + m.setSessions(testSessionStates(3, false)) + m.showDetail = true + m.scrollPos = 5 + + m, _ = m.update(keyMsg("esc")) + assert.False(t, m.showDetail) + assert.Equal(t, 0, m.scrollPos) + }) + + t.Run("enter_on_empty_list", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + + m, _ = m.update(keyMsg("enter")) + assert.False(t, m.showDetail) + }) +} + +func TestActiveModel_ViewStates(t *testing.T) { + t.Parallel() + + t.Run("error_view", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + m.err = errors.New("state error") + + view := m.view(80, 20) + assert.Contains(t, view, "Error loading active sessions") + assert.Contains(t, view, "state error") + }) + + t.Run("empty_view", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + + view := m.view(80, 20) + assert.Contains(t, view, "No active sessions") + }) + + t.Run("list_view", func(t *testing.T) { + t.Parallel() + m := newActiveModel() + m.setSessions(testSessionStates(2, false)) + + view := m.view(80, 20) + assert.Contains(t, view, "Active Sessions") + assert.Contains(t, view, "(2)") + }) +} + +func TestActiveModel_NonKeyMsg(t *testing.T) { + t.Parallel() + + m := newActiveModel() + m.setSessions(testSessionStates(3, false)) + + updated, cmd := m.update(tea.WindowSizeMsg{Width: 80, Height: 40}) + assert.Equal(t, m.cursor, updated.cursor) + assert.Nil(t, cmd) +} diff --git a/cmd/entire/cli/dashboard/checkpoints_tab_test.go b/cmd/entire/cli/dashboard/checkpoints_tab_test.go new file mode 100644 index 000000000..273feaa62 --- /dev/null +++ b/cmd/entire/cli/dashboard/checkpoints_tab_test.go @@ -0,0 +1,398 @@ +package dashboard + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckpointsModel_Navigation(t *testing.T) { + t.Parallel() + + t.Run("j_moves_cursor_down", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(5)) + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 1, m.cursor) + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 2, m.cursor) + }) + + t.Run("k_moves_cursor_up", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(5)) + m.cursor = 3 + + m, _ = m.update(keyMsg("k")) + assert.Equal(t, 2, m.cursor) + }) + + t.Run("cursor_stays_at_bottom_bound", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.cursor = 2 + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 2, m.cursor, "cursor should not exceed last item") + }) + + t.Run("cursor_stays_at_top_bound", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.cursor = 0 + + m, _ = m.update(keyMsg("k")) + assert.Equal(t, 0, m.cursor, "cursor should not go below 0") + }) + + t.Run("down_arrow_works", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + + m, _ = m.update(keyMsg("down")) + assert.Equal(t, 1, m.cursor) + }) + + t.Run("up_arrow_works", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.cursor = 2 + + m, _ = m.update(keyMsg("up")) + assert.Equal(t, 1, m.cursor) + }) +} + +func TestCheckpointsModel_DetailToggle(t *testing.T) { + t.Parallel() + + t.Run("enter_opens_detail", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + + m, _ = m.update(keyMsg("enter")) + assert.True(t, m.showDetail) + assert.Equal(t, 0, m.scrollPos) + }) + + t.Run("enter_closes_detail", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.showDetail = true + + m, _ = m.update(keyMsg("enter")) + assert.False(t, m.showDetail) + }) + + t.Run("esc_closes_detail", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.showDetail = true + m.scrollPos = 5 + + m, _ = m.update(keyMsg("esc")) + assert.False(t, m.showDetail) + assert.Equal(t, 0, m.scrollPos) + }) + + t.Run("enter_on_empty_list_no_op", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + + m, _ = m.update(keyMsg("enter")) + assert.False(t, m.showDetail) + }) + + t.Run("j_scrolls_detail_view", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.showDetail = true + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 1, m.scrollPos) + }) +} + +func TestCheckpointsModel_FilterMode(t *testing.T) { + t.Parallel() + + t.Run("slash_enters_filter_mode", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + + m, _ = m.update(keyMsg("/")) + assert.True(t, m.filtering) + }) + + t.Run("typing_in_filter_mode", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.filtering = true + + m, _ = m.update(keyMsg("a")) + assert.Equal(t, "a", m.filter) + + m, _ = m.update(keyMsg("b")) + assert.Equal(t, "ab", m.filter) + }) + + t.Run("enter_confirms_filter", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.filtering = true + m.filter = "test" + + m, _ = m.update(keyMsg("enter")) + assert.False(t, m.filtering) + assert.Equal(t, "test", m.filter, "filter text should be preserved") + }) + + t.Run("esc_clears_filter", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.filtering = true + m.filter = "test" + + m, _ = m.update(keyMsg("esc")) + assert.False(t, m.filtering) + assert.Empty(t, m.filter) + }) + + t.Run("backspace_removes_last_char", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.filtering = true + m.filter = "abc" + + m, _ = m.update(keyMsg("backspace")) + assert.Equal(t, "ab", m.filter) + }) + + t.Run("backspace_on_empty_filter", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.filtering = true + m.filter = "" + + m, _ = m.update(keyMsg("backspace")) + assert.Empty(t, m.filter) + }) +} + +func TestCheckpointsModel_ApplyFilter(t *testing.T) { + t.Parallel() + + t.Run("case_insensitive_match_on_ID", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + points := testRewindPoints(5) + points[2].ID = "SPECIAL-ID" + m.setCheckpoints(points) + + m.filter = "special" + m.applyFilter() + + assert.Len(t, m.filtered, 1) + assert.Equal(t, "SPECIAL-ID", m.filtered[0].ID) + }) + + t.Run("match_on_message", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + points := testRewindPoints(5) + points[1].Message = "unique fix" + m.setCheckpoints(points) + + m.filter = "unique" + m.applyFilter() + + assert.Len(t, m.filtered, 1) + }) + + t.Run("match_on_session_prompt", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + points := testRewindPoints(5) + points[0].SessionPrompt = "add login feature" + m.setCheckpoints(points) + + m.filter = "login" + m.applyFilter() + + assert.Len(t, m.filtered, 1) + }) + + t.Run("empty_filter_shows_all", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(5)) + + m.filter = "" + m.applyFilter() + + assert.Len(t, m.filtered, 5) + }) +} + +func TestCheckpointsModel_FilterCursorAdjustment(t *testing.T) { + t.Parallel() + + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(10)) + m.cursor = 8 + + // Apply a filter that matches only 2 items + m.filter = "prompt 0" + m.applyFilter() + + assert.LessOrEqual(t, m.cursor, len(m.filtered)-1, "cursor should be clamped to filtered length") +} + +func TestCheckpointsModel_RewindConfirmation(t *testing.T) { + t.Parallel() + + t.Run("r_enters_confirmation", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + + m, _ = m.update(keyMsg("r")) + assert.True(t, m.confirming) + }) + + t.Run("y_confirms_rewind", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.confirming = true + + m, cmd := m.update(keyMsg("y")) + assert.False(t, m.confirming) + require.NotNil(t, cmd) + + msg := cmd() + req, ok := msg.(rewindRequestMsg) + require.True(t, ok) + assert.Equal(t, m.filtered[0].ID, req.pointID) + }) + + t.Run("n_cancels_confirmation", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.confirming = true + + m, _ = m.update(keyMsg("n")) + assert.False(t, m.confirming) + }) + + t.Run("esc_cancels_confirmation", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.confirming = true + + m, _ = m.update(keyMsg("esc")) + assert.False(t, m.confirming) + }) +} + +func TestCheckpointsModel_RewindGuards(t *testing.T) { + t.Parallel() + + t.Run("r_blocked_in_detail_view", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + m.showDetail = true + + m, _ = m.update(keyMsg("r")) + assert.False(t, m.confirming) + }) + + t.Run("r_blocked_on_empty_list", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + + m, _ = m.update(keyMsg("r")) + assert.False(t, m.confirming) + }) +} + +func TestCheckpointsModel_ViewStates(t *testing.T) { + t.Parallel() + + t.Run("error_view", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.err = errors.New("test error") + + view := m.view(80, 20) + assert.Contains(t, view, "Error loading checkpoints") + assert.Contains(t, view, "test error") + }) + + t.Run("empty_list", func(t *testing.T) { + t.Parallel() + m := newCheckpointsModel() + m.setCheckpoints(nil) + + view := m.view(80, 20) + assert.Contains(t, view, "No checkpoints found") + }) + + t.Run("checkpoint_type_labels", func(t *testing.T) { + t.Parallel() + + // Task checkpoint + m := newCheckpointsModel() + points := testRewindPoints(1) + points[0].IsTaskCheckpoint = true + m.setCheckpoints(points) + + view := m.view(80, 20) + assert.Contains(t, view, cpTypeTask) + + // Logs-only (committed) checkpoint + m = newCheckpointsModel() + points = testRewindPoints(1) + points[0].IsLogsOnly = true + m.setCheckpoints(points) + + view = m.view(80, 20) + assert.Contains(t, view, cpTypeCommitted) + }) +} + +func TestCheckpointsModel_NonKeyMsg(t *testing.T) { + t.Parallel() + + m := newCheckpointsModel() + m.setCheckpoints(testRewindPoints(3)) + + // A non-key message should be a no-op + updated, cmd := m.update(tea.WindowSizeMsg{Width: 80, Height: 40}) + assert.Equal(t, m.cursor, updated.cursor) + assert.Nil(t, cmd) +} diff --git a/cmd/entire/cli/dashboard/data.go b/cmd/entire/cli/dashboard/data.go index 2dc93d0a2..4afd8268f 100644 --- a/cmd/entire/cli/dashboard/data.go +++ b/cmd/entire/cli/dashboard/data.go @@ -79,7 +79,7 @@ func loadSettingsCmd() tea.Msg { } // getConfiguredStrategy loads settings and returns the configured strategy. -func getConfiguredStrategy() strategy.Strategy { //nolint:ireturn // factory pattern +func getConfiguredStrategy() strategy.Strategy { s, err := settings.Load() if err != nil { return strategy.Default() diff --git a/cmd/entire/cli/dashboard/data_test.go b/cmd/entire/cli/dashboard/data_test.go new file mode 100644 index 000000000..0c026b176 --- /dev/null +++ b/cmd/entire/cli/dashboard/data_test.go @@ -0,0 +1,60 @@ +package dashboard + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTimeAgo(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + d time.Duration + want string + }{ + {name: "just_now", d: 30 * time.Second, want: "just now"}, + {name: "minutes", d: 15 * time.Minute, want: "15m ago"}, + {name: "one_minute", d: 1 * time.Minute, want: "1m ago"}, + {name: "hours", d: 3 * time.Hour, want: "3h ago"}, + {name: "one_hour", d: 1 * time.Hour, want: "1h ago"}, + {name: "days", d: 48 * time.Hour, want: "2d ago"}, + {name: "one_day", d: 25 * time.Hour, want: "1d ago"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := timeAgo(time.Now().Add(-tt.d)) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTruncate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s string + maxLen int + want string + }{ + {name: "short", s: "hi", maxLen: 10, want: "hi"}, + {name: "exact", s: "hello", maxLen: 5, want: "hello"}, + {name: "long", s: "hello world", maxLen: 8, want: "hello..."}, + {name: "maxLen_less_than_3", s: "hello", maxLen: 2, want: "he"}, + {name: "empty", s: "", maxLen: 5, want: ""}, + {name: "unicode", s: "こんにちは世界", maxLen: 5, want: "こん..."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := truncate(tt.s, tt.maxLen) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/entire/cli/dashboard/model_test.go b/cmd/entire/cli/dashboard/model_test.go new file mode 100644 index 000000000..020d33244 --- /dev/null +++ b/cmd/entire/cli/dashboard/model_test.go @@ -0,0 +1,276 @@ +package dashboard + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +func TestNewModel_DefaultState(t *testing.T) { + t.Parallel() + + m := newModel() + + assert.Equal(t, tabSessions, m.activeTab) + assert.False(t, m.showHelp) + assert.False(t, m.quitting) + assert.Nil(t, m.rewindReq) + require.NoError(t, m.err) + assert.NotNil(t, m.dataLoaded) + assert.Empty(t, m.dataLoaded) +} + +func TestModel_TabSwitching(t *testing.T) { + t.Parallel() + + m := newModel() + + // tab forward: Sessions -> Checkpoints + m, _ = updateModel(t, m, keyMsg("tab")) + assert.Equal(t, tabCheckpoints, m.activeTab) + + // tab forward: Checkpoints -> Active + m, _ = updateModel(t, m, keyMsg("tab")) + assert.Equal(t, tabActive, m.activeTab) + + // tab forward: Active -> Settings + m, _ = updateModel(t, m, keyMsg("tab")) + assert.Equal(t, tabSettings, m.activeTab) + + // tab forward wraps: Settings -> Sessions + m, _ = updateModel(t, m, keyMsg("tab")) + assert.Equal(t, tabSessions, m.activeTab) + + // shift+tab backward wraps: Sessions -> Settings + m, _ = updateModel(t, m, keyMsg("shift+tab")) + assert.Equal(t, tabSettings, m.activeTab) + + // shift+tab backward: Settings -> Active + m, _ = updateModel(t, m, keyMsg("shift+tab")) + assert.Equal(t, tabActive, m.activeTab) +} + +func TestModel_QuitKeys(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + }{ + {name: "q", key: "q"}, + {name: "ctrl+c", key: "ctrl+c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + m := newModel() + m, cmd := updateModel(t, m, keyMsg(tt.key)) + + assert.True(t, m.quitting) + require.NotNil(t, cmd) + // tea.Quit returns a tea.Msg; calling the Cmd should produce a quit message + msg := cmd() + _, isQuit := msg.(tea.QuitMsg) + assert.True(t, isQuit, "expected tea.QuitMsg from quit command") + }) + } +} + +func TestModel_ToggleHelp(t *testing.T) { + t.Parallel() + + m := newModel() + + assert.False(t, m.showHelp) + + m, _ = updateModel(t, m, keyMsg("?")) + assert.True(t, m.showHelp) + + m, _ = updateModel(t, m, keyMsg("?")) + assert.False(t, m.showHelp) +} + +func TestModel_DataMessages(t *testing.T) { + t.Parallel() + + t.Run("sessionsMsg", func(t *testing.T) { + t.Parallel() + m := newModel() + sessions := testSessions(3) + + m, _ = updateModel(t, m, sessionsMsg{sessions: sessions}) + + assert.True(t, m.dataLoaded[tabSessions]) + assert.Len(t, m.sessions.filtered, 3) + }) + + t.Run("checkpointsMsg", func(t *testing.T) { + t.Parallel() + m := newModel() + points := testRewindPoints(5) + + m, _ = updateModel(t, m, checkpointsMsg{points: points}) + + assert.True(t, m.dataLoaded[tabCheckpoints]) + assert.Len(t, m.checkpoints.filtered, 5) + }) + + t.Run("activeSessionsMsg", func(t *testing.T) { + t.Parallel() + m := newModel() + states := testSessionStates(2, false) + + m, _ = updateModel(t, m, activeSessionsMsg{states: states}) + + assert.True(t, m.dataLoaded[tabActive]) + assert.Len(t, m.active.states, 2) + }) + + t.Run("settingsDataMsg", func(t *testing.T) { + t.Parallel() + m := newModel() + s := &settings.EntireSettings{Enabled: true, Strategy: "manual-commit"} + + m, _ = updateModel(t, m, settingsDataMsg{settings: s}) + + assert.True(t, m.dataLoaded[tabSettings]) + assert.NotNil(t, m.settings.data) + }) +} + +func TestModel_DataMessagesWithError(t *testing.T) { + t.Parallel() + + testErr := errors.New("load failed") + + t.Run("sessionsMsg_error", func(t *testing.T) { + t.Parallel() + m := newModel() + m, _ = updateModel(t, m, sessionsMsg{err: testErr}) + assert.Equal(t, testErr, m.sessions.err) + }) + + t.Run("checkpointsMsg_error", func(t *testing.T) { + t.Parallel() + m := newModel() + m, _ = updateModel(t, m, checkpointsMsg{err: testErr}) + assert.Equal(t, testErr, m.checkpoints.err) + }) + + t.Run("activeSessionsMsg_error", func(t *testing.T) { + t.Parallel() + m := newModel() + m, _ = updateModel(t, m, activeSessionsMsg{err: testErr}) + assert.Equal(t, testErr, m.active.err) + }) + + t.Run("settingsDataMsg_error", func(t *testing.T) { + t.Parallel() + m := newModel() + m, _ = updateModel(t, m, settingsDataMsg{err: testErr}) + assert.Equal(t, testErr, m.settings.err) + }) +} + +func TestModel_RewindRequestMsg(t *testing.T) { + t.Parallel() + + m := newModel() + m, cmd := updateModel(t, m, rewindRequestMsg{pointID: "abc123"}) + + require.NotNil(t, m.rewindReq) + assert.Equal(t, "abc123", m.rewindReq.PointID) + assert.True(t, m.quitting) + require.NotNil(t, cmd) +} + +func TestModel_ViewWhileQuitting(t *testing.T) { + t.Parallel() + + m := newModel() + m.quitting = true + + assert.Empty(t, m.View()) +} + +func TestModel_WindowSizeMsg(t *testing.T) { + t.Parallel() + + m := newModel() + m, _ = updateModel(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) + + assert.Equal(t, 120, m.width) + assert.Equal(t, 40, m.height) +} + +func TestModel_IsTabCapturingInput(t *testing.T) { + t.Parallel() + + t.Run("sessions_filtering_blocks_tab", func(t *testing.T) { + t.Parallel() + m := newModel() + m.activeTab = tabSessions + m.sessions.filtering = true + + assert.True(t, m.isTabCapturingInput()) + }) + + t.Run("checkpoints_filtering_blocks_tab", func(t *testing.T) { + t.Parallel() + m := newModel() + m.activeTab = tabCheckpoints + m.checkpoints.filtering = true + + assert.True(t, m.isTabCapturingInput()) + }) + + t.Run("checkpoints_confirming_blocks_tab", func(t *testing.T) { + t.Parallel() + m := newModel() + m.activeTab = tabCheckpoints + m.checkpoints.confirming = true + + assert.True(t, m.isTabCapturingInput()) + }) + + t.Run("active_tab_does_not_capture", func(t *testing.T) { + t.Parallel() + m := newModel() + m.activeTab = tabActive + + assert.False(t, m.isTabCapturingInput()) + }) +} + +func TestModel_ContentHeight(t *testing.T) { + t.Parallel() + + t.Run("normal", func(t *testing.T) { + t.Parallel() + m := newModel() + m.height = 30 + assert.Equal(t, 27, m.contentHeight()) // 30 - 3 overhead + }) + + t.Run("with_help", func(t *testing.T) { + t.Parallel() + m := newModel() + m.height = 30 + m.showHelp = true + assert.Equal(t, 20, m.contentHeight()) // 30 - 10 overhead + }) + + t.Run("minimum", func(t *testing.T) { + t.Parallel() + m := newModel() + m.height = 2 + assert.Equal(t, 5, m.contentHeight()) // minimum is 5 + }) +} diff --git a/cmd/entire/cli/dashboard/sessions_tab_test.go b/cmd/entire/cli/dashboard/sessions_tab_test.go new file mode 100644 index 000000000..3e25b154d --- /dev/null +++ b/cmd/entire/cli/dashboard/sessions_tab_test.go @@ -0,0 +1,220 @@ +package dashboard + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +func TestSessionsModel_Navigation(t *testing.T) { + t.Parallel() + + t.Run("j_moves_cursor_down", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(5)) + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 1, m.cursor) + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 2, m.cursor) + }) + + t.Run("k_moves_cursor_up", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(5)) + m.cursor = 3 + + m, _ = m.update(keyMsg("k")) + assert.Equal(t, 2, m.cursor) + }) + + t.Run("cursor_stays_at_bottom_bound", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + m.cursor = 2 + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 2, m.cursor) + }) + + t.Run("cursor_stays_at_top_bound", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + m.cursor = 0 + + m, _ = m.update(keyMsg("k")) + assert.Equal(t, 0, m.cursor) + }) +} + +func TestSessionsModel_DetailToggle(t *testing.T) { + t.Parallel() + + t.Run("enter_opens_detail", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + + m, _ = m.update(keyMsg("enter")) + assert.True(t, m.showDetail) + assert.Equal(t, 0, m.scrollPos) + }) + + t.Run("esc_closes_detail", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + m.showDetail = true + m.scrollPos = 5 + + m, _ = m.update(keyMsg("esc")) + assert.False(t, m.showDetail) + assert.Equal(t, 0, m.scrollPos) + }) + + t.Run("enter_on_empty_list", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + + m, _ = m.update(keyMsg("enter")) + assert.False(t, m.showDetail) + }) + + t.Run("scrollPos_resets_on_toggle", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + m.showDetail = true + m.scrollPos = 10 + + // Close detail + m, _ = m.update(keyMsg("enter")) + assert.False(t, m.showDetail) + assert.Equal(t, 0, m.scrollPos) + }) +} + +func TestSessionsModel_FilterMode(t *testing.T) { + t.Parallel() + + t.Run("slash_enters_filter", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + + m, _ = m.update(keyMsg("/")) + assert.True(t, m.filtering) + }) + + t.Run("type_filter_text", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(5)) + m.filtering = true + + m, _ = m.update(keyMsg("t")) + m, _ = m.update(keyMsg("e")) + assert.Equal(t, "te", m.filter) + }) + + t.Run("enter_confirms_filter", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + m.filtering = true + m.filter = "xyz" + + m, _ = m.update(keyMsg("enter")) + assert.False(t, m.filtering) + assert.Equal(t, "xyz", m.filter) + }) + + t.Run("esc_clears_filter", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + m.filtering = true + m.filter = "xyz" + + m, _ = m.update(keyMsg("esc")) + assert.False(t, m.filtering) + assert.Empty(t, m.filter) + }) + + t.Run("backspace_removes_char", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(testSessions(3)) + m.filtering = true + m.filter = "abc" + + m, _ = m.update(keyMsg("backspace")) + assert.Equal(t, "ab", m.filter) + }) + + t.Run("filter_matches_description", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + sessions := testSessions(5) + sessions[2].Description = "unique description" + m.setSessions(sessions) + + m.filter = "unique" + m.applyFilter() + assert.Len(t, m.filtered, 1) + }) +} + +func TestSessionsModel_ViewStates(t *testing.T) { + t.Parallel() + + t.Run("error_view", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.err = errors.New("load failed") + + view := m.view(80, 20) + assert.Contains(t, view, "Error loading sessions") + assert.Contains(t, view, "load failed") + }) + + t.Run("empty_list", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + m.setSessions(nil) + + view := m.view(80, 20) + assert.Contains(t, view, "No sessions found") + }) + + t.Run("no_description", func(t *testing.T) { + t.Parallel() + m := newSessionsModel() + sessions := testSessions(1) + sessions[0].Description = strategy.NoDescription + m.setSessions(sessions) + + view := m.view(80, 20) + assert.Contains(t, view, "(no description)") + }) +} + +func TestSessionsModel_NonKeyMsg(t *testing.T) { + t.Parallel() + + m := newSessionsModel() + m.setSessions(testSessions(3)) + + updated, cmd := m.update(tea.WindowSizeMsg{Width: 80, Height: 40}) + assert.Equal(t, m.cursor, updated.cursor) + assert.Nil(t, cmd) +} diff --git a/cmd/entire/cli/dashboard/settings_tab_test.go b/cmd/entire/cli/dashboard/settings_tab_test.go new file mode 100644 index 000000000..cc7ab4a6d --- /dev/null +++ b/cmd/entire/cli/dashboard/settings_tab_test.go @@ -0,0 +1,119 @@ +package dashboard + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +func TestSettingsModel_SetData(t *testing.T) { + t.Parallel() + + m := newSettingsModel() + m.setData(settingsDataMsg{ + settings: &settings.EntireSettings{ + Enabled: true, + Strategy: "manual-commit", + }, + }) + + assert.NotNil(t, m.data) + assert.NotEmpty(t, m.lines) +} + +func TestSettingsModel_Scroll(t *testing.T) { + t.Parallel() + + t.Run("j_scrolls_down", func(t *testing.T) { + t.Parallel() + m := newSettingsModel() + m.height = 5 + // Build some lines to scroll through + m.setData(settingsDataMsg{ + settings: &settings.EntireSettings{ + Enabled: true, + Strategy: "manual-commit", + }, + }) + + m, _ = m.update(keyMsg("j")) + assert.Equal(t, 1, m.scrollPos) + }) + + t.Run("k_scrolls_up", func(t *testing.T) { + t.Parallel() + m := newSettingsModel() + m.height = 5 + m.setData(settingsDataMsg{ + settings: &settings.EntireSettings{ + Enabled: true, + Strategy: "manual-commit", + }, + }) + m.scrollPos = 3 + + m, _ = m.update(keyMsg("k")) + assert.Equal(t, 2, m.scrollPos) + }) + + t.Run("k_stays_at_top", func(t *testing.T) { + t.Parallel() + m := newSettingsModel() + m.scrollPos = 0 + + m, _ = m.update(keyMsg("k")) + assert.Equal(t, 0, m.scrollPos) + }) +} + +func TestSettingsModel_ViewStates(t *testing.T) { + t.Parallel() + + t.Run("error_view", func(t *testing.T) { + t.Parallel() + m := newSettingsModel() + m.err = errors.New("settings error") + + view := m.view(80, 20) + assert.Contains(t, view, "Error loading settings") + assert.Contains(t, view, "settings error") + }) + + t.Run("no_data_view", func(t *testing.T) { + t.Parallel() + m := newSettingsModel() + + view := m.view(80, 20) + assert.Contains(t, view, "No settings data available") + }) + + t.Run("with_data", func(t *testing.T) { + t.Parallel() + m := newSettingsModel() + m.height = 30 + m.setData(settingsDataMsg{ + settings: &settings.EntireSettings{ + Enabled: true, + Strategy: "auto-commit", + }, + }) + + view := m.view(80, 30) + assert.Contains(t, view, "Configuration") + assert.Contains(t, view, "auto-commit") + }) +} + +func TestSettingsModel_NonKeyMsg(t *testing.T) { + t.Parallel() + + m := newSettingsModel() + + updated, cmd := m.update(tea.WindowSizeMsg{Width: 80, Height: 40}) + assert.Equal(t, m.scrollPos, updated.scrollPos) + assert.Nil(t, cmd) +} diff --git a/cmd/entire/cli/dashboard/testhelpers_test.go b/cmd/entire/cli/dashboard/testhelpers_test.go new file mode 100644 index 000000000..85421d75d --- /dev/null +++ b/cmd/entire/cli/dashboard/testhelpers_test.go @@ -0,0 +1,103 @@ +package dashboard + +import ( + "fmt" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// updateModel calls m.Update(msg) and asserts the result is a Model. +func updateModel(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) { + t.Helper() + result, cmd := m.Update(msg) + model, ok := result.(Model) + if !ok { + t.Fatalf("Update returned %T, want dashboard.Model", result) + } + return model, cmd +} + +// keyMsg builds a tea.KeyMsg for common key strings. +func keyMsg(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg(tea.Key{Type: tea.KeyEnter}) + case "esc": + return tea.KeyMsg(tea.Key{Type: tea.KeyEsc}) + case "tab": + return tea.KeyMsg(tea.Key{Type: tea.KeyTab}) + case "shift+tab": + return tea.KeyMsg(tea.Key{Type: tea.KeyShiftTab}) + case "backspace": + return tea.KeyMsg(tea.Key{Type: tea.KeyBackspace}) + case "up": + return tea.KeyMsg(tea.Key{Type: tea.KeyUp}) + case "down": + return tea.KeyMsg(tea.Key{Type: tea.KeyDown}) + case "ctrl+c": + return tea.KeyMsg(tea.Key{Type: tea.KeyCtrlC}) + default: + // Single character rune keys (j, k, q, ?, /, r, y, n, etc.) + return tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune(s)}) + } +} + +// testRewindPoints generates n test RewindPoint entries. +func testRewindPoints(n int) []strategy.RewindPoint { + points := make([]strategy.RewindPoint, n) + for i := range n { + points[i] = strategy.RewindPoint{ + ID: fmt.Sprintf("abc123def%03d", i), + Message: fmt.Sprintf("checkpoint %d", i), + Date: time.Now().Add(-time.Duration(i) * time.Hour), + CheckpointID: id.CheckpointID(fmt.Sprintf("cp%010d", i)), + Agent: agent.AgentTypeClaudeCode, + SessionID: fmt.Sprintf("session-%d", i), + SessionPrompt: fmt.Sprintf("prompt %d", i), + } + } + return points +} + +// testSessions generates n test Session entries. +func testSessions(n int) []strategy.Session { + sessions := make([]strategy.Session, n) + for i := range n { + sessions[i] = strategy.Session{ + ID: fmt.Sprintf("2026-01-01-session-%d", i), + Description: fmt.Sprintf("test session %d", i), + Strategy: "manual-commit", + StartTime: time.Now().Add(-time.Duration(i) * time.Hour), + } + } + return sessions +} + +// testSessionStates generates n test session.State entries. +// If includeEnded is true, the last entry will have EndedAt set. +func testSessionStates(n int, includeEnded bool) []*session.State { + states := make([]*session.State, n) + for i := range n { + s := &session.State{ + SessionID: fmt.Sprintf("sess-%d", i), + BaseCommit: fmt.Sprintf("abc123%d", i), + StartedAt: time.Now().Add(-time.Duration(i) * time.Hour), + Phase: session.PhaseActive, + AgentType: agent.AgentTypeClaudeCode, + } + if includeEnded && i == n-1 { + ended := time.Now() + s.EndedAt = &ended + s.Phase = session.PhaseEnded + } + states[i] = s + } + return states +} diff --git a/cmd/entire/cli/dashboard_cmd_test.go b/cmd/entire/cli/dashboard_cmd_test.go new file mode 100644 index 000000000..7d34c5b45 --- /dev/null +++ b/cmd/entire/cli/dashboard_cmd_test.go @@ -0,0 +1,57 @@ +package cli + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/stretchr/testify/assert" +) + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agentType agent.AgentType + sessionID string + want string + }{ + { + name: "claude_code", + agentType: agent.AgentTypeClaudeCode, + sessionID: "abc123", + want: "claude -r abc123", + }, + { + name: "gemini", + agentType: agent.AgentTypeGemini, + sessionID: "xyz789", + want: "gemini --resume xyz789", + }, + { + name: "unknown_agent_returns_empty", + agentType: "nonexistent-agent", + sessionID: "abc123", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := formatResumeCommand(tt.agentType, tt.sessionID) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNewDashboardCmd_Exists(t *testing.T) { + t.Parallel() + + cmd := newDashboardCmd() + assert.Equal(t, "dashboard", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotNil(t, cmd.RunE) +}