From 3e3a1b0f4cc6bfeb87abbe86b84b65a43c16cb6e Mon Sep 17 00:00:00 2001 From: evisdren Date: Fri, 20 Feb 2026 10:55:03 -0800 Subject: [PATCH 1/7] Redesign `entire status` with styled output and session cards Add lipgloss-based styling to the status command with colored dots, separator-delimited fields, session cards showing phase/files/tokens, and aggregate footer stats. Falls back to plain text for non-terminals. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 7c52cb84aa82 --- cmd/entire/cli/status.go | 144 ++++++++++++------ cmd/entire/cli/status_style.go | 173 +++++++++++++++++++++ cmd/entire/cli/status_test.go | 268 ++++++++++++++++++++++++++------- go.mod | 2 +- 4 files changed, 489 insertions(+), 98 deletions(-) create mode 100644 cmd/entire/cli/status_style.go diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index a81639451..eb619fe27 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "sort" + "strconv" "strings" "time" @@ -71,33 +72,35 @@ func runStatus(w io.Writer, detailed bool) error { return nil } + sty := newStatusStyles(w) + if detailed { - return runStatusDetailed(w, settingsPath, localSettingsPath, projectExists, localExists) + return runStatusDetailed(w, sty, settingsPath, localSettingsPath, projectExists, localExists) } // Short output: just show the effective/merged state - settings, err := LoadEntireSettings() + s, err := LoadEntireSettings() if err != nil { return fmt.Errorf("failed to load settings: %w", err) } - fmt.Fprintln(w, formatSettingsStatusShort(settings)) + fmt.Fprintln(w, formatSettingsStatusShort(s, sty)) - if settings.Enabled { - writeActiveSessions(w) + if s.Enabled { + writeActiveSessions(w, sty) } return nil } // runStatusDetailed shows the effective status plus detailed status for each settings file. -func runStatusDetailed(w io.Writer, settingsPath, localSettingsPath string, projectExists, localExists bool) error { +func runStatusDetailed(w io.Writer, sty statusStyles, settingsPath, localSettingsPath string, projectExists, localExists bool) error { // First show the effective/merged status effectiveSettings, err := LoadEntireSettings() if err != nil { return fmt.Errorf("failed to load settings: %w", err) } - fmt.Fprintln(w, formatSettingsStatusShort(effectiveSettings)) + fmt.Fprintln(w, formatSettingsStatusShort(effectiveSettings, sty)) fmt.Fprintln(w) // blank line // Show project settings if it exists @@ -106,7 +109,7 @@ func runStatusDetailed(w io.Writer, settingsPath, localSettingsPath string, proj if err != nil { return fmt.Errorf("failed to load project settings: %w", err) } - fmt.Fprintln(w, formatSettingsStatus("Project", projectSettings)) + fmt.Fprintln(w, formatSettingsStatus("Project", projectSettings, sty)) } // Show local settings if it exists @@ -115,42 +118,73 @@ func runStatusDetailed(w io.Writer, settingsPath, localSettingsPath string, proj if err != nil { return fmt.Errorf("failed to load local settings: %w", err) } - fmt.Fprintln(w, formatSettingsStatus("Local", localSettings)) + fmt.Fprintln(w, formatSettingsStatus("Local", localSettings, sty)) } if effectiveSettings.Enabled { - writeActiveSessions(w) + writeActiveSessions(w, sty) } return nil } // formatSettingsStatusShort formats a short settings status line. -// Output format: "Enabled (manual-commit)" or "Disabled (auto-commit)" -func formatSettingsStatusShort(settings *EntireSettings) string { - displayName := settings.Strategy - if dn, ok := strategyInternalToDisplay[settings.Strategy]; ok { +// Output format: "● Enabled · manual-commit · branch main" or "○ Disabled · auto-commit" +func formatSettingsStatusShort(s *EntireSettings, sty statusStyles) string { + displayName := s.Strategy + if dn, ok := strategyInternalToDisplay[s.Strategy]; ok { displayName = dn } - if settings.Enabled { - return fmt.Sprintf("Enabled (%s)", displayName) + var b strings.Builder + + if s.Enabled { + b.WriteString(sty.render(sty.green, "●")) + b.WriteString(" ") + b.WriteString(sty.render(sty.bold, "Enabled")) + } else { + b.WriteString(sty.render(sty.red, "○")) + b.WriteString(" ") + b.WriteString(sty.render(sty.bold, "Disabled")) + } + + b.WriteString(sty.render(sty.dim, " · ")) + b.WriteString(displayName) + + // Resolve branch from repo root + if repoRoot, err := paths.RepoRoot(); err == nil { + if branch := resolveWorktreeBranch(repoRoot); branch != "" { + b.WriteString(sty.render(sty.dim, " · ")) + b.WriteString("branch ") + b.WriteString(sty.render(sty.cyan, branch)) + } } - return fmt.Sprintf("Disabled (%s)", displayName) + + return b.String() } // formatSettingsStatus formats a settings status line with source prefix. -// Output format: "Project, enabled (manual-commit)" or "Local, disabled (auto-commit)" -func formatSettingsStatus(prefix string, settings *EntireSettings) string { - displayName := settings.Strategy - if dn, ok := strategyInternalToDisplay[settings.Strategy]; ok { +// Output format: "Project · enabled · manual-commit" or "Local · disabled · auto-commit" +func formatSettingsStatus(prefix string, s *EntireSettings, sty statusStyles) string { + displayName := s.Strategy + if dn, ok := strategyInternalToDisplay[s.Strategy]; ok { displayName = dn } - if settings.Enabled { - return fmt.Sprintf("%s, enabled (%s)", prefix, displayName) + var b strings.Builder + b.WriteString(sty.render(sty.bold, prefix)) + b.WriteString(sty.render(sty.dim, " · ")) + + if s.Enabled { + b.WriteString("enabled") + } else { + b.WriteString("disabled") } - return fmt.Sprintf("%s, disabled (%s)", prefix, displayName) + + b.WriteString(sty.render(sty.dim, " · ")) + b.WriteString(displayName) + + return b.String() } // timeAgo formats a time as a human-readable relative duration. @@ -181,7 +215,7 @@ type worktreeGroup struct { const unknownPlaceholder = "(unknown)" // writeActiveSessions writes active session information grouped by worktree. -func writeActiveSessions(w io.Writer) { +func writeActiveSessions(w io.Writer, sty statusStyles) { store, err := session.NewStateStore() if err != nil { return @@ -241,16 +275,18 @@ func writeActiveSessions(w io.Writer) { }) } + // Track aggregate totals + var totalSessions, totalFiles, totalTok int + fmt.Fprintln(w) - fmt.Fprintln(w, "Active Sessions:") - for i, g := range sortedGroups { - header := g.path - if g.branch != "" { - header += " (" + g.branch + ")" - } - fmt.Fprintf(w, " %s\n", header) + for _, g := range sortedGroups { + displayName := worktreeDisplayName(g.path) + fmt.Fprintln(w, sty.sectionRule("Active Sessions", displayName, sty.width)) + fmt.Fprintln(w) for _, st := range g.sessions { + totalSessions++ + agentLabel := string(st.AgentType) if agentLabel == "" { agentLabel = unknownPlaceholder @@ -261,29 +297,45 @@ func writeActiveSessions(w io.Writer) { shortID = shortID[:7] } - age := "started " + timeAgo(st.StartedAt) + // Line 1: Agent · shortID ● phase + fmt.Fprintf(w, "%s %s %s\n", + sty.render(sty.agent, agentLabel), + sty.render(sty.dim, "·"), + shortID+" "+sty.phaseIndicator(st.Phase)) - // Show "active X ago" when LastInteractionTime differs meaningfully from StartedAt - activeStr := "" - if st.LastInteractionTime != nil && st.LastInteractionTime.Sub(st.StartedAt) > time.Minute { - activeStr = ", active " + timeAgo(*st.LastInteractionTime) + // Line 2: "first prompt" (quoted, truncated) + if st.FirstPrompt != "" { + prompt := stringutil.TruncateRunes(st.FirstPrompt, 60, "...") + fmt.Fprintf(w, "\"%s\"\n", prompt) } - fmt.Fprintf(w, " [%s] %-9s %s%s\n", - agentLabel, shortID, age, activeStr) + // Line 3: stats line — started Xd ago · active now · files N · tokens X.Xk + var stats []string + stats = append(stats, "started "+timeAgo(st.StartedAt)) - // Show first prompt on indented second line - if st.FirstPrompt != "" { - prompt := stringutil.TruncateRunes(st.FirstPrompt, 60, "...") - fmt.Fprintf(w, " \"%s\"\n", prompt) + if st.LastInteractionTime != nil && st.LastInteractionTime.Sub(st.StartedAt) > time.Minute { + stats = append(stats, activeTimeDisplay(st.LastInteractionTime)) } - } - // Blank line between groups, but not after the last one - if i < len(sortedGroups)-1 { + fileCount := len(st.FilesTouched) + totalFiles += fileCount + stats = append(stats, "files "+sty.render(sty.bold, strconv.Itoa(fileCount))) + + tok := totalTokens(st.TokenUsage) + totalTok += tok + stats = append(stats, "tokens "+sty.render(sty.bold, formatTokenCount(tok))) + + statsLine := strings.Join(stats, sty.render(sty.dim, " · ")) + fmt.Fprintln(w, sty.render(sty.dim, statsLine)) fmt.Fprintln(w) } } + + // Footer: horizontal rule + aggregate totals + fmt.Fprintln(w, sty.horizontalRule(sty.width)) + footer := fmt.Sprintf("%d sessions · %d files · %s tokens", + totalSessions, totalFiles, formatTokenCount(totalTok)) + fmt.Fprintln(w, sty.render(sty.dim, footer)) } // resolveWorktreeBranch resolves the current branch for a worktree path. diff --git a/cmd/entire/cli/status_style.go b/cmd/entire/cli/status_style.go new file mode 100644 index 000000000..c95d65666 --- /dev/null +++ b/cmd/entire/cli/status_style.go @@ -0,0 +1,173 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/session" + + "golang.org/x/term" +) + +// statusStyles holds pre-built lipgloss styles and terminal metadata. +type statusStyles struct { + colorEnabled bool + width int + + // Styles + green lipgloss.Style + red lipgloss.Style + yellow lipgloss.Style + gray lipgloss.Style + bold lipgloss.Style + dim lipgloss.Style + agent lipgloss.Style // amber/orange for agent names + cyan lipgloss.Style +} + +// newStatusStyles creates styles appropriate for the output writer. +func newStatusStyles(w io.Writer) statusStyles { + useColor := shouldUseColor(w) + width := getTerminalWidth() + + s := statusStyles{ + colorEnabled: useColor, + width: width, + } + + if useColor { + s.green = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + s.red = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + s.yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + s.gray = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + s.bold = lipgloss.NewStyle().Bold(true) + s.dim = lipgloss.NewStyle().Faint(true) + s.agent = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + s.cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + } + + return s +} + +// render applies a style to text only when color is enabled. +func (s statusStyles) render(style lipgloss.Style, text string) string { + if !s.colorEnabled { + return text + } + return style.Render(text) +} + +// shouldUseColor returns true if the writer supports color output. +func shouldUseColor(w io.Writer) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + if f, ok := w.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false +} + +// getTerminalWidth returns the terminal width, capped at 80 with a fallback of 60. +func getTerminalWidth() int { + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + if w > 80 { + return 80 + } + return w + } + return 60 +} + +// formatTokenCount formats a token count for display. +// 0 → "0", 500 → "500", 1200 → "1.2k", 14300 → "14.3k" +func formatTokenCount(n int) string { + if n < 1000 { + return strconv.Itoa(n) + } + f := float64(n) / 1000.0 + s := fmt.Sprintf("%.1f", f) + // Remove trailing ".0" for clean display (e.g., 1000 → "1k" not "1.0k") + s = strings.TrimSuffix(s, ".0") + return s + "k" +} + +// totalTokens recursively sums all token fields including subagent tokens. +func totalTokens(tu *agent.TokenUsage) int { + if tu == nil { + return 0 + } + total := tu.InputTokens + tu.CacheCreationTokens + tu.CacheReadTokens + tu.OutputTokens + total += totalTokens(tu.SubagentTokens) + return total +} + +// horizontalRule renders a dimmed horizontal rule of the given width. +func (s statusStyles) horizontalRule(width int) string { + rule := strings.Repeat("─", width) + return s.render(s.dim, rule) +} + +// sectionRule renders a section header like: ── Active Sessions · repo-name ──── +func (s statusStyles) sectionRule(label, highlight string, width int) string { + prefix := "── " + content := label + " · " + highlight + " " + // Calculate remaining space for trailing rule + // Count visible characters (no ANSI escapes in the plain text) + usedWidth := len(prefix) + len(content) + trailing := width - usedWidth + if trailing < 1 { + trailing = 1 + } + + var b strings.Builder + b.WriteString(s.render(s.dim, "── ")) + b.WriteString(s.render(s.dim, label)) + b.WriteString(s.render(s.dim, " · ")) + b.WriteString(s.render(s.cyan, highlight)) + b.WriteString(" ") + b.WriteString(s.render(s.dim, strings.Repeat("─", trailing))) + return b.String() +} + +// phaseIndicator returns a colored dot + phase text. +func (s statusStyles) phaseIndicator(phase session.Phase) string { + switch phase { + case session.PhaseActive: + return s.render(s.green, "●") + " " + s.render(s.green, "active") + case session.PhaseIdle: + return s.render(s.yellow, "●") + " " + s.render(s.yellow, "idle") + case session.PhaseEnded: + return s.render(s.gray, "●") + " " + s.render(s.gray, "ended") + default: + return s.render(s.gray, "●") + " " + s.render(s.gray, "idle") + } +} + +// activeTimeDisplay formats a last interaction time for display. +// Returns "active now" for recent activity (<1min), otherwise "active Xm ago". +func activeTimeDisplay(lastInteraction *time.Time) string { + if lastInteraction == nil { + return "" + } + d := time.Since(*lastInteraction) + if d < time.Minute { + return "active now" + } + return "active " + timeAgo(*lastInteraction) +} + +// worktreeDisplayName returns the base name of a worktree path for compact display. +func worktreeDisplayName(path string) string { + if path == unknownPlaceholder { + return path + } + return filepath.Base(path) +} diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index a54dfb983..058735310 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -79,15 +79,18 @@ func TestRunStatus_LocalSettingsOnly(t *testing.T) { } output := stdout.String() - // Should show effective status first - if !strings.Contains(output, "Enabled (auto-commit)") { - t.Errorf("Expected output to show effective 'Enabled (auto-commit)', got: %s", output) + // Should show effective status first (dot + Enabled + separator + strategy) + if !strings.Contains(output, "Enabled") { + t.Errorf("Expected output to show 'Enabled', got: %s", output) + } + if !strings.Contains(output, "auto-commit") { + t.Errorf("Expected output to show 'auto-commit', got: %s", output) } // Should show per-file details - if !strings.Contains(output, "Local, enabled") { - t.Errorf("Expected output to show 'Local, enabled', got: %s", output) + if !strings.Contains(output, "Local") || !strings.Contains(output, "enabled") { + t.Errorf("Expected output to show 'Local' and 'enabled', got: %s", output) } - if strings.Contains(output, "Project,") { + if strings.Contains(output, "Project") { t.Errorf("Should not show Project settings when only local exists, got: %s", output) } } @@ -107,15 +110,15 @@ func TestRunStatus_BothProjectAndLocal(t *testing.T) { output := stdout.String() // Should show effective status first (local overrides project) - if !strings.Contains(output, "Disabled (auto-commit)") { - t.Errorf("Expected output to show effective 'Disabled (auto-commit)', got: %s", output) + if !strings.Contains(output, "Disabled") || !strings.Contains(output, "auto-commit") { + t.Errorf("Expected output to show effective 'Disabled' with 'auto-commit', got: %s", output) } // Should show both settings separately - if !strings.Contains(output, "Project, enabled (manual-commit)") { - t.Errorf("Expected output to show 'Project, enabled (manual-commit)', got: %s", output) + if !strings.Contains(output, "Project") || !strings.Contains(output, "manual-commit") { + t.Errorf("Expected output to show Project with manual-commit, got: %s", output) } - if !strings.Contains(output, "Local, disabled (auto-commit)") { - t.Errorf("Expected output to show 'Local, disabled (auto-commit)', got: %s", output) + if !strings.Contains(output, "Local") || !strings.Contains(output, "disabled") { + t.Errorf("Expected output to show Local with disabled, got: %s", output) } } @@ -134,8 +137,8 @@ func TestRunStatus_BothProjectAndLocal_Short(t *testing.T) { output := stdout.String() // Should show merged/effective state (local overrides project) - if !strings.Contains(output, "Disabled (auto-commit)") { - t.Errorf("Expected output to show 'Disabled (auto-commit)', got: %s", output) + if !strings.Contains(output, "Disabled") || !strings.Contains(output, "auto-commit") { + t.Errorf("Expected output to show 'Disabled' with 'auto-commit', got: %s", output) } } @@ -149,8 +152,8 @@ func TestRunStatus_ShowsStrategy(t *testing.T) { } output := stdout.String() - if !strings.Contains(output, "(auto-commit)") { - t.Errorf("Expected output to show strategy '(auto-commit)', got: %s", output) + if !strings.Contains(output, "auto-commit") { + t.Errorf("Expected output to show strategy 'auto-commit', got: %s", output) } } @@ -165,12 +168,12 @@ func TestRunStatus_ShowsManualCommitStrategy(t *testing.T) { output := stdout.String() // Should show effective status first - if !strings.Contains(output, "Disabled (manual-commit)") { - t.Errorf("Expected output to show effective 'Disabled (manual-commit)', got: %s", output) + if !strings.Contains(output, "Disabled") || !strings.Contains(output, "manual-commit") { + t.Errorf("Expected output to show effective 'Disabled' with 'manual-commit', got: %s", output) } // Should show per-file details - if !strings.Contains(output, "Project, disabled (manual-commit)") { - t.Errorf("Expected output to show 'Project, disabled (manual-commit)', got: %s", output) + if !strings.Contains(output, "Project") || !strings.Contains(output, "disabled") { + t.Errorf("Expected output to show 'Project' and 'disabled', got: %s", output) } } @@ -245,33 +248,26 @@ func TestWriteActiveSessions(t *testing.T) { } var buf bytes.Buffer - writeActiveSessions(&buf) + sty := newStatusStyles(&buf) + writeActiveSessions(&buf, sty) output := buf.String() - // Should contain "Active Sessions:" header - if !strings.Contains(output, "Active Sessions:") { - t.Errorf("Expected 'Active Sessions:' header, got: %s", output) - } - - // Should contain worktree paths - if !strings.Contains(output, "/Users/test/repo") { - t.Errorf("Expected worktree path '/Users/test/repo', got: %s", output) - } - if !strings.Contains(output, "/Users/test/repo/.worktrees/3") { - t.Errorf("Expected worktree path '/Users/test/repo/.worktrees/3', got: %s", output) + // Should contain "Active Sessions" in section header + if !strings.Contains(output, "Active Sessions") { + t.Errorf("Expected 'Active Sessions' header, got: %s", output) } - // Should contain agent labels - if !strings.Contains(output, "[Claude Code]") { - t.Errorf("Expected agent label '[Claude Code]', got: %s", output) + // Should contain agent labels (without brackets in new format) + if !strings.Contains(output, "Claude Code") { + t.Errorf("Expected agent label 'Claude Code', got: %s", output) } - if !strings.Contains(output, "[Cursor]") { - t.Errorf("Expected agent label '[Cursor]', got: %s", output) + if !strings.Contains(output, "Cursor") { + t.Errorf("Expected agent label 'Cursor', got: %s", output) } // Session without AgentType should show unknown placeholder - if !strings.Contains(output, "[(unknown)]") { - t.Errorf("Expected '[(unknown)]' for missing agent type, got: %s", output) + if !strings.Contains(output, unknownPlaceholder) { + t.Errorf("Expected '%s' for missing agent type, got: %s", unknownPlaceholder, output) } // Should contain truncated session IDs @@ -279,37 +275,39 @@ func TestWriteActiveSessions(t *testing.T) { t.Errorf("Expected truncated session ID 'abc-123', got: %s", output) } - // Should contain first prompts on indented second line + // Should contain first prompts in quotes if !strings.Contains(output, "\"Fix auth bug in login flow\"") { t.Errorf("Expected first prompt text in quotes, got: %s", output) } // Session without FirstPrompt should NOT show a prompt line - // (no more "(unknown)" in quotes for missing prompts) lines := strings.Split(output, "\n") for _, line := range lines { if strings.Contains(line, "ghi-901") { - // The line with the no-prompt session should not have a prompt if strings.Contains(line, "\"") { t.Errorf("Session without prompt should not show quoted text on first line, got: %s", line) } } } - // Should show "active X ago" for session with LastInteractionTime that differs from StartedAt + // Should show "active 5m ago" for session with LastInteractionTime that differs from StartedAt if !strings.Contains(output, "active 5m ago") { t.Errorf("Expected 'active 5m ago' for session with LastInteractionTime, got: %s", output) } - // Session started 15m ago with no LastInteractionTime should NOT show "active" text - // Find the Cursor session line and verify no "active" in it + // Session started 15m ago with no LastInteractionTime should NOT show "active" in stats for _, line := range lines { - if strings.Contains(line, "[Cursor]") { + if strings.Contains(line, "Cursor") { if strings.Contains(line, "active") { t.Errorf("Session without LastInteractionTime should not show 'active', got: %s", line) } } } + + // Should contain aggregate footer + if !strings.Contains(output, "3 sessions") { + t.Errorf("Expected aggregate '3 sessions' in footer, got: %s", output) + } } func TestWriteActiveSessions_ActiveTimeOmittedWhenClose(t *testing.T) { @@ -339,11 +337,14 @@ func TestWriteActiveSessions_ActiveTimeOmittedWhenClose(t *testing.T) { } var buf bytes.Buffer - writeActiveSessions(&buf) + sty := newStatusStyles(&buf) + writeActiveSessions(&buf, sty) output := buf.String() - if strings.Contains(output, "active") { - t.Errorf("Expected no 'active' when LastInteractionTime is close to StartedAt, got: %s", output) + // Should not show "active Xm ago" when LastInteractionTime is close to StartedAt + // But "active" may appear in phase indicator, so check for the specific pattern + if strings.Contains(output, "active 10m ago") || strings.Contains(output, "active 9m ago") { + t.Errorf("Expected no separate 'active' time when LastInteractionTime is close to StartedAt, got: %s", output) } } @@ -351,7 +352,8 @@ func TestWriteActiveSessions_NoSessions(t *testing.T) { setupTestRepo(t) var buf bytes.Buffer - writeActiveSessions(&buf) + sty := newStatusStyles(&buf) + writeActiveSessions(&buf, sty) // Should produce no output when there are no sessions if buf.Len() != 0 { @@ -380,10 +382,174 @@ func TestWriteActiveSessions_EndedSessionsExcluded(t *testing.T) { } var buf bytes.Buffer - writeActiveSessions(&buf) + sty := newStatusStyles(&buf) + writeActiveSessions(&buf, sty) // Should produce no output when all sessions are ended if buf.Len() != 0 { t.Errorf("Expected empty output with only ended sessions, got: %s", buf.String()) } } + +func TestFormatTokenCount(t *testing.T) { + t.Parallel() + + tests := []struct { + input int + want string + }{ + {0, "0"}, + {500, "500"}, + {999, "999"}, + {1000, "1k"}, + {1200, "1.2k"}, + {4800, "4.8k"}, + {14300, "14.3k"}, + {100000, "100k"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + t.Parallel() + got := formatTokenCount(tt.input) + if got != tt.want { + t.Errorf("formatTokenCount(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestTotalTokens(t *testing.T) { + t.Parallel() + + t.Run("nil", func(t *testing.T) { + t.Parallel() + if got := totalTokens(nil); got != 0 { + t.Errorf("totalTokens(nil) = %d, want 0", got) + } + }) + + t.Run("basic", func(t *testing.T) { + t.Parallel() + tu := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 50, + } + if got := totalTokens(tu); got != 150 { + t.Errorf("totalTokens() = %d, want 150", got) + } + }) + + t.Run("with subagents", func(t *testing.T) { + t.Parallel() + tu := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 50, + SubagentTokens: &agent.TokenUsage{ + InputTokens: 200, + OutputTokens: 100, + }, + } + if got := totalTokens(tu); got != 450 { + t.Errorf("totalTokens() = %d, want 450", got) + } + }) + + t.Run("all fields", func(t *testing.T) { + t.Parallel() + tu := &agent.TokenUsage{ + InputTokens: 100, + CacheCreationTokens: 50, + CacheReadTokens: 25, + OutputTokens: 75, + } + if got := totalTokens(tu); got != 250 { + t.Errorf("totalTokens() = %d, want 250", got) + } + }) +} + +func TestPhaseIndicator(t *testing.T) { + t.Parallel() + + // Use a non-terminal writer so color is disabled — output is plain text + var buf bytes.Buffer + sty := newStatusStyles(&buf) + + tests := []struct { + phase session.Phase + want string + }{ + {session.PhaseActive, "● active"}, + {session.PhaseIdle, "● idle"}, + {session.PhaseEnded, "● ended"}, + {"", "● idle"}, // empty defaults to idle + } + + for _, tt := range tests { + t.Run(string(tt.phase), func(t *testing.T) { + t.Parallel() + got := sty.phaseIndicator(tt.phase) + if got != tt.want { + t.Errorf("phaseIndicator(%q) = %q, want %q", tt.phase, got, tt.want) + } + }) + } +} + +func TestActiveTimeDisplay(t *testing.T) { + t.Parallel() + + t.Run("nil", func(t *testing.T) { + t.Parallel() + if got := activeTimeDisplay(nil); got != "" { + t.Errorf("activeTimeDisplay(nil) = %q, want empty", got) + } + }) + + t.Run("recent", func(t *testing.T) { + t.Parallel() + now := time.Now() + if got := activeTimeDisplay(&now); got != "active now" { + t.Errorf("activeTimeDisplay(now) = %q, want 'active now'", got) + } + }) + + t.Run("older", func(t *testing.T) { + t.Parallel() + older := time.Now().Add(-5 * time.Minute) + got := activeTimeDisplay(&older) + if got != "active 5m ago" { + t.Errorf("activeTimeDisplay(-5m) = %q, want 'active 5m ago'", got) + } + }) +} + +func TestShouldUseColor(t *testing.T) { + t.Parallel() + + // bytes.Buffer is not a terminal → should return false + var buf bytes.Buffer + if shouldUseColor(&buf) { + t.Error("shouldUseColor(bytes.Buffer) should be false") + } +} + +func TestHorizontalRule(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + sty := newStatusStyles(&buf) + + rule := sty.horizontalRule(20) + if len([]rune(rule)) != 20 { + t.Errorf("horizontalRule(20) has %d runes, want 20", len([]rune(rule))) + } + // All characters should be the box-drawing dash + for _, r := range rule { + if r != '─' { + t.Errorf("horizontalRule contains unexpected rune %q", r) + break + } + } +} diff --git a/go.mod b/go.mod index 3aed29571..c0ce2add6 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.6 require ( 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 @@ -36,7 +37,6 @@ require ( 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 d961ec3af8c2cf8e0d7c67ba14751af38efb8d43 Mon Sep 17 00:00:00 2001 From: evisdren Date: Fri, 20 Feb 2026 11:29:14 -0800 Subject: [PATCH 2/7] Remove phase indicator and file counts, add TTY tests Drop session phase (active/idle/ended) and per-session file counts from status output. Token calculation validated correct (sums Input+CacheCreation+CacheRead+Output recursively, excludes APICallCount). Add comprehensive TTY/color detection tests: NO_COLOR env, regular file, non-TTY buffer, render toggle, plain-text section rules. Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/status.go | 15 +-- cmd/entire/cli/status_style.go | 31 ++--- cmd/entire/cli/status_test.go | 213 +++++++++++++++++++++++++++++---- 3 files changed, 201 insertions(+), 58 deletions(-) diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index eb619fe27..d7d61065f 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -9,7 +9,6 @@ import ( "os" "os/exec" "sort" - "strconv" "strings" "time" @@ -276,7 +275,7 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { } // Track aggregate totals - var totalSessions, totalFiles, totalTok int + var totalSessions, totalTok int fmt.Fprintln(w) for _, g := range sortedGroups { @@ -297,11 +296,11 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { shortID = shortID[:7] } - // Line 1: Agent · shortID ● phase + // Line 1: Agent · shortID fmt.Fprintf(w, "%s %s %s\n", sty.render(sty.agent, agentLabel), sty.render(sty.dim, "·"), - shortID+" "+sty.phaseIndicator(st.Phase)) + shortID) // Line 2: "first prompt" (quoted, truncated) if st.FirstPrompt != "" { @@ -317,10 +316,6 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { stats = append(stats, activeTimeDisplay(st.LastInteractionTime)) } - fileCount := len(st.FilesTouched) - totalFiles += fileCount - stats = append(stats, "files "+sty.render(sty.bold, strconv.Itoa(fileCount))) - tok := totalTokens(st.TokenUsage) totalTok += tok stats = append(stats, "tokens "+sty.render(sty.bold, formatTokenCount(tok))) @@ -333,8 +328,8 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { // Footer: horizontal rule + aggregate totals fmt.Fprintln(w, sty.horizontalRule(sty.width)) - footer := fmt.Sprintf("%d sessions · %d files · %s tokens", - totalSessions, totalFiles, formatTokenCount(totalTok)) + footer := fmt.Sprintf("%d sessions · %s tokens", + totalSessions, formatTokenCount(totalTok)) fmt.Fprintln(w, sty.render(sty.dim, footer)) } diff --git a/cmd/entire/cli/status_style.go b/cmd/entire/cli/status_style.go index c95d65666..dd08f31a5 100644 --- a/cmd/entire/cli/status_style.go +++ b/cmd/entire/cli/status_style.go @@ -11,7 +11,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/session" "golang.org/x/term" ) @@ -22,14 +21,13 @@ type statusStyles struct { width int // Styles - green lipgloss.Style - red lipgloss.Style - yellow lipgloss.Style - gray lipgloss.Style - bold lipgloss.Style - dim lipgloss.Style - agent lipgloss.Style // amber/orange for agent names - cyan lipgloss.Style + green lipgloss.Style + red lipgloss.Style + gray lipgloss.Style + bold lipgloss.Style + dim lipgloss.Style + agent lipgloss.Style // amber/orange for agent names + cyan lipgloss.Style } // newStatusStyles creates styles appropriate for the output writer. @@ -45,7 +43,6 @@ func newStatusStyles(w io.Writer) statusStyles { if useColor { s.green = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) s.red = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - s.yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) s.gray = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) s.bold = lipgloss.NewStyle().Bold(true) s.dim = lipgloss.NewStyle().Faint(true) @@ -137,20 +134,6 @@ func (s statusStyles) sectionRule(label, highlight string, width int) string { return b.String() } -// phaseIndicator returns a colored dot + phase text. -func (s statusStyles) phaseIndicator(phase session.Phase) string { - switch phase { - case session.PhaseActive: - return s.render(s.green, "●") + " " + s.render(s.green, "active") - case session.PhaseIdle: - return s.render(s.yellow, "●") + " " + s.render(s.yellow, "idle") - case session.PhaseEnded: - return s.render(s.gray, "●") + " " + s.render(s.gray, "ended") - default: - return s.render(s.gray, "●") + " " + s.render(s.gray, "idle") - } -} - // activeTimeDisplay formats a last interaction time for display. // Returns "active now" for recent activity (<1min), otherwise "active Xm ago". func activeTimeDisplay(lastInteraction *time.Time) string { diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 058735310..e8e650740 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -3,10 +3,12 @@ package cli import ( "bytes" "context" + "os" "strings" "testing" "time" + "github.com/charmbracelet/lipgloss" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/session" ) @@ -217,7 +219,7 @@ func TestWriteActiveSessions(t *testing.T) { now := time.Now() recentInteraction := now.Add(-5 * time.Minute) - // Create active sessions + // Create active sessions with token usage states := []*session.State{ { SessionID: "abc-1234-session", @@ -226,6 +228,10 @@ func TestWriteActiveSessions(t *testing.T) { LastInteractionTime: &recentInteraction, FirstPrompt: "Fix auth bug in login flow", AgentType: agent.AgentType("Claude Code"), + TokenUsage: &agent.TokenUsage{ + InputTokens: 800, + OutputTokens: 400, + }, }, { SessionID: "def-5678-session", @@ -233,6 +239,10 @@ func TestWriteActiveSessions(t *testing.T) { StartedAt: now.Add(-15 * time.Minute), FirstPrompt: "Add dark mode support for the entire application and all components", AgentType: agent.AgentType("Cursor"), + TokenUsage: &agent.TokenUsage{ + InputTokens: 500, + OutputTokens: 300, + }, }, { SessionID: "ghi-9012-session", @@ -304,10 +314,28 @@ func TestWriteActiveSessions(t *testing.T) { } } - // Should contain aggregate footer + // Should contain per-session token counts + if !strings.Contains(output, "tokens 1.2k") { + t.Errorf("Expected per-session 'tokens 1.2k' for first session (800+400), got: %s", output) + } + + // Should contain aggregate footer with total tokens (800+400+500+300 = 2000 → "2k") if !strings.Contains(output, "3 sessions") { t.Errorf("Expected aggregate '3 sessions' in footer, got: %s", output) } + if !strings.Contains(output, "2k tokens") { + t.Errorf("Expected aggregate '2k tokens' in footer, got: %s", output) + } + + // Should NOT contain phase indicators (removed) + if strings.Contains(output, "● active") || strings.Contains(output, "● idle") || strings.Contains(output, "● ended") { + t.Errorf("Output should not contain phase indicators, got: %s", output) + } + + // Should NOT contain file counts (removed) + if strings.Contains(output, "files ") { + t.Errorf("Output should not contain file counts, got: %s", output) + } } func TestWriteActiveSessions_ActiveTimeOmittedWhenClose(t *testing.T) { @@ -469,31 +497,39 @@ func TestTotalTokens(t *testing.T) { }) } -func TestPhaseIndicator(t *testing.T) { +func TestTotalTokens_ExcludesAPICallCount(t *testing.T) { t.Parallel() - // Use a non-terminal writer so color is disabled — output is plain text - var buf bytes.Buffer - sty := newStatusStyles(&buf) - - tests := []struct { - phase session.Phase - want string - }{ - {session.PhaseActive, "● active"}, - {session.PhaseIdle, "● idle"}, - {session.PhaseEnded, "● ended"}, - {"", "● idle"}, // empty defaults to idle + // APICallCount should NOT be included in token totals — it's a separate metric + tu := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 50, + APICallCount: 999, // should be ignored + } + got := totalTokens(tu) + if got != 150 { + t.Errorf("totalTokens() = %d, want 150 (APICallCount should be excluded)", got) } +} - for _, tt := range tests { - t.Run(string(tt.phase), func(t *testing.T) { - t.Parallel() - got := sty.phaseIndicator(tt.phase) - if got != tt.want { - t.Errorf("phaseIndicator(%q) = %q, want %q", tt.phase, got, tt.want) - } - }) +func TestTotalTokens_DeepSubagentNesting(t *testing.T) { + t.Parallel() + + tu := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 50, + SubagentTokens: &agent.TokenUsage{ + InputTokens: 200, + OutputTokens: 100, + SubagentTokens: &agent.TokenUsage{ + InputTokens: 50, + OutputTokens: 25, + }, + }, + } + // 100+50 + 200+100 + 50+25 = 525 + if got := totalTokens(tu); got != 525 { + t.Errorf("totalTokens() = %d, want 525 (deep nesting)", got) } } @@ -525,7 +561,7 @@ func TestActiveTimeDisplay(t *testing.T) { }) } -func TestShouldUseColor(t *testing.T) { +func TestShouldUseColor_NonTTY(t *testing.T) { t.Parallel() // bytes.Buffer is not a terminal → should return false @@ -535,6 +571,135 @@ func TestShouldUseColor(t *testing.T) { } } +func TestShouldUseColor_NoColorEnv(t *testing.T) { + // NO_COLOR env var should force color off even for a real file + t.Setenv("NO_COLOR", "1") + + f, err := os.CreateTemp(t.TempDir(), "test") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if shouldUseColor(f) { + t.Error("shouldUseColor should be false when NO_COLOR is set") + } +} + +func TestShouldUseColor_RegularFile(t *testing.T) { + t.Parallel() + + // A regular file (not a terminal) should return false + f, err := os.CreateTemp(t.TempDir(), "test") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if shouldUseColor(f) { + t.Error("shouldUseColor(regular file) should be false") + } +} + +func TestNewStatusStyles_NonTTY(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + sty := newStatusStyles(&buf) + + if sty.colorEnabled { + t.Error("newStatusStyles(bytes.Buffer) should have colorEnabled=false") + } +} + +func TestRender_ColorDisabled(t *testing.T) { + t.Parallel() + + // When color is disabled, render should return text unchanged + sty := statusStyles{colorEnabled: false} + style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")) + + got := sty.render(style, "hello") + if got != "hello" { + t.Errorf("render with color disabled = %q, want %q", got, "hello") + } +} + +func TestRender_ColorEnabled_CallsStyleRender(t *testing.T) { + t.Parallel() + + // When colorEnabled=true, render should call style.Render (not return plain text). + // Note: lipgloss may strip ANSI in test environments without a terminal, so we + // can't assert ANSI codes. Instead, verify the code path is exercised and + // the text content is preserved. + sty := statusStyles{ + colorEnabled: true, + bold: lipgloss.NewStyle().Bold(true), + } + + got := sty.render(sty.bold, "hello") + if !strings.Contains(got, "hello") { + t.Errorf("render with color enabled should preserve text content, got: %q", got) + } +} + +func TestRender_ColorToggle(t *testing.T) { + t.Parallel() + + style := lipgloss.NewStyle().Bold(true) + + // Color disabled: must return exact input + styOff := statusStyles{colorEnabled: false} + got := styOff.render(style, "test") + if got != "test" { + t.Errorf("render(colorEnabled=false) = %q, want exact %q", got, "test") + } + + // Color enabled: exercises style.Render code path, text preserved + styOn := statusStyles{colorEnabled: true} + got = styOn.render(style, "test") + if !strings.Contains(got, "test") { + t.Errorf("render(colorEnabled=true) should contain 'test', got: %q", got) + } +} + +func TestSectionRule_PlainText(t *testing.T) { + t.Parallel() + + sty := statusStyles{colorEnabled: false, width: 40} + rule := sty.sectionRule("Active Sessions", "myrepo", 40) + + // Plain text should contain the label and highlight + if !strings.Contains(rule, "Active Sessions") { + t.Errorf("sectionRule should contain label, got: %q", rule) + } + if !strings.Contains(rule, "myrepo") { + t.Errorf("sectionRule should contain highlight, got: %q", rule) + } + if !strings.Contains(rule, "─") { + t.Errorf("sectionRule should contain rule characters, got: %q", rule) + } + // With color disabled, should have no ANSI escapes + if strings.Contains(rule, "\x1b[") { + t.Errorf("sectionRule with color disabled should have no ANSI escapes, got: %q", rule) + } +} + +func TestHorizontalRule_PlainText(t *testing.T) { + t.Parallel() + + sty := statusStyles{colorEnabled: false} + rule := sty.horizontalRule(15) + + // Should be no ANSI escapes + if strings.Contains(rule, "\x1b[") { + t.Errorf("horizontalRule with color disabled should have no ANSI escapes, got: %q", rule) + } + if len([]rune(rule)) != 15 { + t.Errorf("horizontalRule(15) has %d runes, want 15", len([]rune(rule))) + } +} + func TestHorizontalRule(t *testing.T) { t.Parallel() From a9320aa519b60578c9b5568cdf992f8db7b777a6 Mon Sep 17 00:00:00 2001 From: evisdren Date: Fri, 20 Feb 2026 12:04:44 -0800 Subject: [PATCH 3/7] Simplify status output: add chevron, clean up header/footer - Add > chevron before first prompt in session cards - Remove repo name from Active Sessions section header - Remove total tokens from aggregate footer (keep session count) - Add blank line above the status line for visual separation Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/status.go | 20 +++++++++----------- cmd/entire/cli/status_style.go | 19 +++---------------- cmd/entire/cli/status_test.go | 18 ++++++------------ 3 files changed, 18 insertions(+), 39 deletions(-) diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index d7d61065f..8bb21f3b0 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -83,6 +83,7 @@ func runStatus(w io.Writer, detailed bool) error { return fmt.Errorf("failed to load settings: %w", err) } + fmt.Fprintln(w) fmt.Fprintln(w, formatSettingsStatusShort(s, sty)) if s.Enabled { @@ -99,6 +100,7 @@ func runStatusDetailed(w io.Writer, sty statusStyles, settingsPath, localSetting if err != nil { return fmt.Errorf("failed to load settings: %w", err) } + fmt.Fprintln(w) fmt.Fprintln(w, formatSettingsStatusShort(effectiveSettings, sty)) fmt.Fprintln(w) // blank line @@ -275,12 +277,11 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { } // Track aggregate totals - var totalSessions, totalTok int + var totalSessions int fmt.Fprintln(w) for _, g := range sortedGroups { - displayName := worktreeDisplayName(g.path) - fmt.Fprintln(w, sty.sectionRule("Active Sessions", displayName, sty.width)) + fmt.Fprintln(w, sty.sectionRule("Active Sessions", sty.width)) fmt.Fprintln(w) for _, st := range g.sessions { @@ -302,10 +303,10 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { sty.render(sty.dim, "·"), shortID) - // Line 2: "first prompt" (quoted, truncated) + // Line 2: > "first prompt" (chevron + quoted, truncated) if st.FirstPrompt != "" { prompt := stringutil.TruncateRunes(st.FirstPrompt, 60, "...") - fmt.Fprintf(w, "\"%s\"\n", prompt) + fmt.Fprintf(w, "%s \"%s\"\n", sty.render(sty.dim, ">"), prompt) } // Line 3: stats line — started Xd ago · active now · files N · tokens X.Xk @@ -316,9 +317,7 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { stats = append(stats, activeTimeDisplay(st.LastInteractionTime)) } - tok := totalTokens(st.TokenUsage) - totalTok += tok - stats = append(stats, "tokens "+sty.render(sty.bold, formatTokenCount(tok))) + stats = append(stats, "tokens "+sty.render(sty.bold, formatTokenCount(totalTokens(st.TokenUsage)))) statsLine := strings.Join(stats, sty.render(sty.dim, " · ")) fmt.Fprintln(w, sty.render(sty.dim, statsLine)) @@ -326,10 +325,9 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { } } - // Footer: horizontal rule + aggregate totals + // Footer: horizontal rule + session count fmt.Fprintln(w, sty.horizontalRule(sty.width)) - footer := fmt.Sprintf("%d sessions · %s tokens", - totalSessions, formatTokenCount(totalTok)) + footer := fmt.Sprintf("%d sessions", totalSessions) fmt.Fprintln(w, sty.render(sty.dim, footer)) } diff --git a/cmd/entire/cli/status_style.go b/cmd/entire/cli/status_style.go index dd08f31a5..46d9e2782 100644 --- a/cmd/entire/cli/status_style.go +++ b/cmd/entire/cli/status_style.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "os" - "path/filepath" "strconv" "strings" "time" @@ -112,12 +111,10 @@ func (s statusStyles) horizontalRule(width int) string { return s.render(s.dim, rule) } -// sectionRule renders a section header like: ── Active Sessions · repo-name ──── -func (s statusStyles) sectionRule(label, highlight string, width int) string { +// sectionRule renders a section header like: ── Active Sessions ──────────── +func (s statusStyles) sectionRule(label string, width int) string { prefix := "── " - content := label + " · " + highlight + " " - // Calculate remaining space for trailing rule - // Count visible characters (no ANSI escapes in the plain text) + content := label + " " usedWidth := len(prefix) + len(content) trailing := width - usedWidth if trailing < 1 { @@ -127,8 +124,6 @@ func (s statusStyles) sectionRule(label, highlight string, width int) string { var b strings.Builder b.WriteString(s.render(s.dim, "── ")) b.WriteString(s.render(s.dim, label)) - b.WriteString(s.render(s.dim, " · ")) - b.WriteString(s.render(s.cyan, highlight)) b.WriteString(" ") b.WriteString(s.render(s.dim, strings.Repeat("─", trailing))) return b.String() @@ -146,11 +141,3 @@ func activeTimeDisplay(lastInteraction *time.Time) string { } return "active " + timeAgo(*lastInteraction) } - -// worktreeDisplayName returns the base name of a worktree path for compact display. -func worktreeDisplayName(path string) string { - if path == unknownPlaceholder { - return path - } - return filepath.Base(path) -} diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index e8e650740..64915452e 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -285,9 +285,9 @@ func TestWriteActiveSessions(t *testing.T) { t.Errorf("Expected truncated session ID 'abc-123', got: %s", output) } - // Should contain first prompts in quotes - if !strings.Contains(output, "\"Fix auth bug in login flow\"") { - t.Errorf("Expected first prompt text in quotes, got: %s", output) + // Should contain first prompts with chevron + if !strings.Contains(output, "> \"Fix auth bug in login flow\"") { + t.Errorf("Expected first prompt with chevron, got: %s", output) } // Session without FirstPrompt should NOT show a prompt line @@ -319,13 +319,10 @@ func TestWriteActiveSessions(t *testing.T) { t.Errorf("Expected per-session 'tokens 1.2k' for first session (800+400), got: %s", output) } - // Should contain aggregate footer with total tokens (800+400+500+300 = 2000 → "2k") + // Should contain aggregate footer with session count (no total tokens in footer) if !strings.Contains(output, "3 sessions") { t.Errorf("Expected aggregate '3 sessions' in footer, got: %s", output) } - if !strings.Contains(output, "2k tokens") { - t.Errorf("Expected aggregate '2k tokens' in footer, got: %s", output) - } // Should NOT contain phase indicators (removed) if strings.Contains(output, "● active") || strings.Contains(output, "● idle") || strings.Contains(output, "● ended") { @@ -667,15 +664,12 @@ func TestSectionRule_PlainText(t *testing.T) { t.Parallel() sty := statusStyles{colorEnabled: false, width: 40} - rule := sty.sectionRule("Active Sessions", "myrepo", 40) + rule := sty.sectionRule("Active Sessions", 40) - // Plain text should contain the label and highlight + // Plain text should contain the label if !strings.Contains(rule, "Active Sessions") { t.Errorf("sectionRule should contain label, got: %q", rule) } - if !strings.Contains(rule, "myrepo") { - t.Errorf("sectionRule should contain highlight, got: %q", rule) - } if !strings.Contains(rule, "─") { t.Errorf("sectionRule should contain rule characters, got: %q", rule) } From f48ef4b3ee84f32ff78935316eedc4f7b13757b6 Mon Sep 17 00:00:00 2001 From: evisdren Date: Fri, 20 Feb 2026 12:07:20 -0800 Subject: [PATCH 4/7] Add trailing newline after session count footer Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/status.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index 8bb21f3b0..f79519c35 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -329,6 +329,7 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { fmt.Fprintln(w, sty.horizontalRule(sty.width)) footer := fmt.Sprintf("%d sessions", totalSessions) fmt.Fprintln(w, sty.render(sty.dim, footer)) + fmt.Fprintln(w) } // resolveWorktreeBranch resolves the current branch for a worktree path. From 7666dd982b6b408c71df2484c8beca1888d04a4b Mon Sep 17 00:00:00 2001 From: evisdren Date: Fri, 20 Feb 2026 12:24:51 -0800 Subject: [PATCH 5/7] Use output writer for terminal width detection getTerminalWidth now checks the output writer's fd first, then falls back to Stdout/Stderr. This ensures correct width when output is redirected to a different file descriptor. Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/status_style.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/status_style.go b/cmd/entire/cli/status_style.go index 46d9e2782..803c56959 100644 --- a/cmd/entire/cli/status_style.go +++ b/cmd/entire/cli/status_style.go @@ -32,7 +32,7 @@ type statusStyles struct { // newStatusStyles creates styles appropriate for the output writer. func newStatusStyles(w io.Writer) statusStyles { useColor := shouldUseColor(w) - width := getTerminalWidth() + width := getTerminalWidth(w) s := statusStyles{ colorEnabled: useColor, @@ -72,13 +72,25 @@ func shouldUseColor(w io.Writer) bool { } // getTerminalWidth returns the terminal width, capped at 80 with a fallback of 60. -func getTerminalWidth() int { - if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { - if w > 80 { - return 80 +// It first checks the writer itself, then falls back to Stdout/Stderr. +func getTerminalWidth(w io.Writer) int { + // Try the output writer first + if f, ok := w.(*os.File); ok { + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { + return min(width, 80) + } + } + + // Fall back to Stdout, then Stderr + for _, f := range []*os.File{os.Stdout, os.Stderr} { + if f == nil { + continue + } + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { + return min(width, 80) } - return w } + return 60 } From 572d3864d16419653060e1195fdfa3cab87c1a40 Mon Sep 17 00:00:00 2001 From: evisdren Date: Fri, 20 Feb 2026 12:29:50 -0800 Subject: [PATCH 6/7] Add test coverage for status style helpers and formatting functions Covers getTerminalWidth, newStatusStyles width, sectionRule narrow edge case, activeTimeDisplay hours/days, and unit tests for formatSettingsStatusShort and formatSettingsStatus. Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/status_test.go | 188 ++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 64915452e..6920f7b8a 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -712,3 +712,191 @@ func TestHorizontalRule(t *testing.T) { } } } + +func TestGetTerminalWidth_NonTTY(t *testing.T) { + t.Parallel() + + // A bytes.Buffer is not a terminal — should fall back to 60 + var buf bytes.Buffer + width := getTerminalWidth(&buf) + // In CI/test environments without a real terminal on Stdout/Stderr, + // the fallback should be 60. If running in a terminal, it may be + // capped at 80. Either is acceptable. + if width != 60 && width > 80 { + t.Errorf("getTerminalWidth(bytes.Buffer) = %d, want 60 or ≤80", width) + } +} + +func TestGetTerminalWidth_RegularFile(t *testing.T) { + t.Parallel() + + // A regular file (not a terminal) should not report a terminal width + f, err := os.CreateTemp(t.TempDir(), "test") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + width := getTerminalWidth(f) + // Regular file fd won't have a terminal size, so it should fall back + if width != 60 && width > 80 { + t.Errorf("getTerminalWidth(regular file) = %d, want 60 or ≤80", width) + } +} + +func TestNewStatusStyles_Width(t *testing.T) { + t.Parallel() + + // For a non-terminal writer, width should be the fallback (60) + // unless Stdout/Stderr happen to be terminals + var buf bytes.Buffer + sty := newStatusStyles(&buf) + + if sty.width == 0 { + t.Error("newStatusStyles should set a non-zero width") + } + if sty.width > 80 { + t.Errorf("newStatusStyles width = %d, should be capped at 80", sty.width) + } +} + +func TestSectionRule_NarrowWidth(t *testing.T) { + t.Parallel() + + // When width is very small (smaller than prefix + label), trailing should be at least 1 + sty := statusStyles{colorEnabled: false, width: 10} + rule := sty.sectionRule("Active Sessions", 10) + + // Should still contain the label and at least one trailing dash + if !strings.Contains(rule, "Active Sessions") { + t.Errorf("sectionRule with narrow width should still contain label, got: %q", rule) + } + if !strings.Contains(rule, "─") { + t.Errorf("sectionRule with narrow width should have at least one trailing dash, got: %q", rule) + } +} + +func TestActiveTimeDisplay_Hours(t *testing.T) { + t.Parallel() + + hoursAgo := time.Now().Add(-3 * time.Hour) + got := activeTimeDisplay(&hoursAgo) + if got != "active 3h ago" { + t.Errorf("activeTimeDisplay(-3h) = %q, want 'active 3h ago'", got) + } +} + +func TestActiveTimeDisplay_Days(t *testing.T) { + t.Parallel() + + daysAgo := time.Now().Add(-48 * time.Hour) + got := activeTimeDisplay(&daysAgo) + if got != "active 2d ago" { + t.Errorf("activeTimeDisplay(-48h) = %q, want 'active 2d ago'", got) + } +} + +func TestFormatSettingsStatusShort_Enabled(t *testing.T) { + setupTestRepo(t) + + sty := statusStyles{colorEnabled: false, width: 60} + s := &EntireSettings{ + Enabled: true, + Strategy: "manual-commit", + } + + result := formatSettingsStatusShort(s, sty) + + if !strings.Contains(result, "●") { + t.Errorf("Enabled status should have green dot, got: %q", result) + } + if !strings.Contains(result, "Enabled") { + t.Errorf("Expected 'Enabled' in output, got: %q", result) + } + if !strings.Contains(result, "manual-commit") { + t.Errorf("Expected strategy in output, got: %q", result) + } +} + +func TestFormatSettingsStatusShort_Disabled(t *testing.T) { + setupTestRepo(t) + + sty := statusStyles{colorEnabled: false, width: 60} + s := &EntireSettings{ + Enabled: false, + Strategy: "auto-commit", + } + + result := formatSettingsStatusShort(s, sty) + + if !strings.Contains(result, "○") { + t.Errorf("Disabled status should have open dot, got: %q", result) + } + if !strings.Contains(result, "Disabled") { + t.Errorf("Expected 'Disabled' in output, got: %q", result) + } + if !strings.Contains(result, "auto-commit") { + t.Errorf("Expected strategy in output, got: %q", result) + } +} + +func TestFormatSettingsStatus_Project(t *testing.T) { + t.Parallel() + + sty := statusStyles{colorEnabled: false, width: 60} + s := &EntireSettings{ + Enabled: true, + Strategy: "manual-commit", + } + + result := formatSettingsStatus("Project", s, sty) + + if !strings.Contains(result, "Project") { + t.Errorf("Expected 'Project' prefix, got: %q", result) + } + if !strings.Contains(result, "enabled") { + t.Errorf("Expected 'enabled' in output, got: %q", result) + } + if !strings.Contains(result, "manual-commit") { + t.Errorf("Expected strategy in output, got: %q", result) + } +} + +func TestFormatSettingsStatus_LocalDisabled(t *testing.T) { + t.Parallel() + + sty := statusStyles{colorEnabled: false, width: 60} + s := &EntireSettings{ + Enabled: false, + Strategy: "auto-commit", + } + + result := formatSettingsStatus("Local", s, sty) + + if !strings.Contains(result, "Local") { + t.Errorf("Expected 'Local' prefix, got: %q", result) + } + if !strings.Contains(result, "disabled") { + t.Errorf("Expected 'disabled' in output, got: %q", result) + } + if !strings.Contains(result, "auto-commit") { + t.Errorf("Expected strategy in output, got: %q", result) + } +} + +func TestFormatSettingsStatus_Separators(t *testing.T) { + t.Parallel() + + sty := statusStyles{colorEnabled: false, width: 60} + s := &EntireSettings{ + Enabled: true, + Strategy: "manual-commit", + } + + result := formatSettingsStatus("Project", s, sty) + + // Should use · as separator (plain text, no ANSI) + if !strings.Contains(result, "·") { + t.Errorf("Expected '·' separators in output, got: %q", result) + } +} From 797e36b2bb660ce28a82347e1d7714ce6aa49621 Mon Sep 17 00:00:00 2001 From: evisdren Date: Fri, 20 Feb 2026 12:36:29 -0800 Subject: [PATCH 7/7] clean up and fix --- cmd/entire/cli/status.go | 17 +++++++++++++---- cmd/entire/cli/status_style.go | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index f79519c35..f9240a206 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -280,9 +280,13 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { var totalSessions int fmt.Fprintln(w) + printedHeader := false for _, g := range sortedGroups { - fmt.Fprintln(w, sty.sectionRule("Active Sessions", sty.width)) - fmt.Fprintln(w) + if !printedHeader { + fmt.Fprintln(w, sty.sectionRule("Active Sessions", sty.width)) + fmt.Fprintln(w) + printedHeader = true + } for _, st := range g.sessions { totalSessions++ @@ -317,7 +321,7 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { stats = append(stats, activeTimeDisplay(st.LastInteractionTime)) } - stats = append(stats, "tokens "+sty.render(sty.bold, formatTokenCount(totalTokens(st.TokenUsage)))) + stats = append(stats, "tokens "+formatTokenCount(totalTokens(st.TokenUsage))) statsLine := strings.Join(stats, sty.render(sty.dim, " · ")) fmt.Fprintln(w, sty.render(sty.dim, statsLine)) @@ -327,7 +331,12 @@ func writeActiveSessions(w io.Writer, sty statusStyles) { // Footer: horizontal rule + session count fmt.Fprintln(w, sty.horizontalRule(sty.width)) - footer := fmt.Sprintf("%d sessions", totalSessions) + var footer string + if totalSessions == 1 { + footer = "1 session" + } else { + footer = fmt.Sprintf("%d sessions", totalSessions) + } fmt.Fprintln(w, sty.render(sty.dim, footer)) fmt.Fprintln(w) } diff --git a/cmd/entire/cli/status_style.go b/cmd/entire/cli/status_style.go index 803c56959..d492a785b 100644 --- a/cmd/entire/cli/status_style.go +++ b/cmd/entire/cli/status_style.go @@ -127,7 +127,7 @@ func (s statusStyles) horizontalRule(width int) string { func (s statusStyles) sectionRule(label string, width int) string { prefix := "── " content := label + " " - usedWidth := len(prefix) + len(content) + usedWidth := len([]rune(prefix)) + len([]rune(content)) trailing := width - usedWidth if trailing < 1 { trailing = 1