diff --git a/daemon/internal/ctlserver/handler.go b/daemon/internal/ctlserver/handler.go index 9ba4904..a3e7ee1 100644 --- a/daemon/internal/ctlserver/handler.go +++ b/daemon/internal/ctlserver/handler.go @@ -15,20 +15,22 @@ import ( ) type ctlRequest struct { - Action string `json:"action"` - SessionID string `json:"session_id,omitempty"` - PRURL string `json:"pr_url,omitempty"` // for add_pr - PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr, cycle_pr_autopilot, set_merge_method - MergeMethod string `json:"merge_method,omitempty"` // for set_merge_method + Action string `json:"action"` + SessionID string `json:"session_id,omitempty"` + PRURL string `json:"pr_url,omitempty"` // for add_pr + PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr, cycle_pr_autopilot, set_merge_method + MergeMethod string `json:"merge_method,omitempty"` // for set_merge_method + DefaultAutopilot string `json:"default_autopilot,omitempty"` // for set_default_autopilot } type ctlResponse struct { - OK *bool `json:"ok,omitempty"` - Sessions []model.Session `json:"sessions,omitempty"` - PRs []pr.TrackedPR `json:"prs,omitempty"` - Event string `json:"event,omitempty"` - AutopilotMode string `json:"autopilot_mode,omitempty"` - NewRepo bool `json:"new_repo,omitempty"` // true when add_pr is the first PR for this repo + OK *bool `json:"ok,omitempty"` + Sessions []model.Session `json:"sessions,omitempty"` + PRs []pr.TrackedPR `json:"prs,omitempty"` + Event string `json:"event,omitempty"` + AutopilotMode string `json:"autopilot_mode,omitempty"` + NewRepo bool `json:"new_repo,omitempty"` // true when add_pr is the first PR for this repo + DefaultAutopilot string `json:"default_autopilot,omitempty"` } type Handler struct { @@ -76,6 +78,10 @@ func (h *Handler) Handle(conn net.Conn) { h.handleSetMergeMethod(conn, req.PRKey, req.MergeMethod) case "toggle_review": h.handleToggleReview(conn, req.PRKey) + case "set_default_autopilot": + h.handleSetDefaultAutopilot(conn, req.DefaultAutopilot) + case "get_config": + h.handleGetConfig(conn) } } } @@ -102,8 +108,9 @@ func (h *Handler) handleSubscribe(conn net.Conn) { func (h *Handler) stateSnapshot() ctlResponse { resp := ctlResponse{ - Event: "state_updated", - Sessions: h.state.GetSessions(), + Event: "state_updated", + Sessions: h.state.GetSessions(), + DefaultAutopilot: h.state.GetDefaultAutopilot(), } if h.prPoll != nil { resp.PRs = h.prPoll.GetAll() @@ -274,6 +281,18 @@ func (h *Handler) handleToggleReview(conn net.Conn, key string) { writeJSON(conn, ctlResponse{OK: &ok}) } +func (h *Handler) handleSetDefaultAutopilot(conn net.Conn, mode string) { + h.state.SetDefaultAutopilot(mode) + ok := true + writeJSON(conn, ctlResponse{OK: &ok, DefaultAutopilot: mode}) +} + +func (h *Handler) handleGetConfig(conn net.Conn) { + mode := h.state.GetDefaultAutopilot() + ok := true + writeJSON(conn, ctlResponse{OK: &ok, DefaultAutopilot: mode}) +} + func writeJSON(conn net.Conn, v any) { data, _ := json.Marshal(v) data = append(data, '\n') diff --git a/daemon/internal/state/config.go b/daemon/internal/state/config.go new file mode 100644 index 0000000..113cd26 --- /dev/null +++ b/daemon/internal/state/config.go @@ -0,0 +1,59 @@ +package state + +import ( + "encoding/json" + "log" + "os" + "path/filepath" +) + +// Config holds daemon-wide configuration persisted to ~/.csm/config.json. +type Config struct { + // DefaultAutopilot is the autopilot mode applied to newly discovered + // sessions that have no persisted per-session override. + // Valid values: "" (none), "on", "yolo". + DefaultAutopilot string `json:"default_autopilot"` +} + +// configPath returns the path to the config file (~/.csm/config.json). +func configPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".csm", "config.json") +} + +// loadConfig reads ~/.csm/config.json and returns the parsed Config. +// Missing file is treated as empty Config (zero value). +func loadConfig(path string) Config { + if path == "" { + return Config{} + } + data, err := os.ReadFile(path) + if err != nil { + // File not found is normal on first run. + return Config{} + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + log.Printf("state: failed to parse config %s: %v", path, err) + return Config{} + } + return cfg +} + +// saveConfig writes cfg to the given path as JSON. +func saveConfig(path string, cfg Config) { + if path == "" { + return + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + log.Printf("state: failed to marshal config: %v", err) + return + } + if err := os.WriteFile(path, data, 0o644); err != nil { + log.Printf("state: failed to save config to %s: %v", path, err) + } +} diff --git a/daemon/internal/state/state.go b/daemon/internal/state/state.go index 637f3f8..63d79f1 100644 --- a/daemon/internal/state/state.go +++ b/daemon/internal/state/state.go @@ -44,6 +44,11 @@ type Manager struct { subMu sync.Mutex autopilotPath string + + // config holds daemon-wide settings (e.g., default autopilot mode). + config Config + // configFilePath is the path to ~/.csm/config.json (empty = no persistence). + configFilePath string } // New creates a new state Manager, loading persisted autopilot state. @@ -61,6 +66,8 @@ func New() *Manager { _ = os.MkdirAll(dir, 0o755) m.autopilotPath = filepath.Join(dir, "autopilot.json") m.loadAutopilot() + m.configFilePath = filepath.Join(dir, "config.json") + m.config = loadConfig(m.configFilePath) } return m @@ -79,6 +86,8 @@ func NewWithDir(dir string) *Manager { _ = os.MkdirAll(dir, 0o755) m.autopilotPath = filepath.Join(dir, "autopilot.json") m.loadAutopilot() + m.configFilePath = filepath.Join(dir, "config.json") + m.config = loadConfig(m.configFilePath) } return m } @@ -101,9 +110,12 @@ func (m *Manager) RegisterSession(sid, cwd, permMode string) { s.PermissionMode = permMode s.ProjectName = filepath.Base(cwd) - // Restore persisted autopilot state. + // Restore persisted autopilot state; fall back to config default for new sessions. if mode, ok := m.autopilot[sid]; ok && mode != "" { s.AutopilotMode = mode + } else if !exists && m.config.DefaultAutopilot != "" { + s.AutopilotMode = m.config.DefaultAutopilot + log.Printf("state: applied default autopilot %s to session %s", m.config.DefaultAutopilot, sid) } m.notifySubscribers() @@ -123,6 +135,20 @@ func (m *Manager) UnregisterSession(sid string) { m.notifySubscribers() } +// applyDefaultAutopilot sets the session's AutopilotMode to the configured +// default when the session has no persisted per-session mode. Caller must +// hold m.mu (write lock). +func (m *Manager) applyDefaultAutopilot(s *model.Session) { + if _, hasPersisted := m.autopilot[s.SessionID]; hasPersisted { + // Per-session persisted state already handled separately; skip. + return + } + if s.AutopilotMode == "" && m.config.DefaultAutopilot != "" { + s.AutopilotMode = m.config.DefaultAutopilot + log.Printf("state: applied default autopilot %s to session %s", m.config.DefaultAutopilot, s.SessionID) + } +} + // UpdateSessionFromScanner merges scanner-discovered session data. func (m *Manager) UpdateSessionFromScanner(s *model.Session) { m.mu.Lock() @@ -130,9 +156,11 @@ func (m *Manager) UpdateSessionFromScanner(s *model.Session) { existing, ok := m.sessions[s.SessionID] if !ok { - // New session from scanner. + // New session from scanner — restore persisted mode or apply default. if mode, okAP := m.autopilot[s.SessionID]; okAP && mode != "" { s.AutopilotMode = mode + } else { + m.applyDefaultAutopilot(s) } m.sessions[s.SessionID] = s m.notifySubscribers() @@ -467,3 +495,24 @@ func (m *Manager) saveAutopilot() { log.Printf("failed to save autopilot state: %v", err) } } + +// GetDefaultAutopilot returns the daemon-wide default autopilot mode. +// Empty string means no default (sessions start with autopilot off). +func (m *Manager) GetDefaultAutopilot() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.config.DefaultAutopilot +} + +// SetDefaultAutopilot updates the daemon-wide default autopilot mode and +// persists it to ~/.csm/config.json. Valid values: "" (none), "on", "yolo". +func (m *Manager) SetDefaultAutopilot(mode string) { + m.mu.Lock() + m.config.DefaultAutopilot = mode + cfgPath := m.configFilePath + cfg := m.config + m.mu.Unlock() + + saveConfig(cfgPath, cfg) + log.Printf("state: default autopilot set to %q", mode) +} diff --git a/daemon/internal/state/state_test.go b/daemon/internal/state/state_test.go index 6099438..0b1ca14 100644 --- a/daemon/internal/state/state_test.go +++ b/daemon/internal/state/state_test.go @@ -858,3 +858,114 @@ func TestResolvePendingClearsPendingTools(t *testing.T) { t.Error("HasDestructive should be false after resolve") } } + +// --- Default autopilot --- + +// newTestManagerWithDefault creates a Manager without disk persistence but +// with a pre-configured default autopilot mode. +func newTestManagerWithDefault(defaultMode string) *Manager { + return &Manager{ + sessions: make(map[string]*model.Session), + autopilot: make(map[string]string), + pending: make(map[string]*model.PendingApproval), + cooldowns: make(map[string]time.Time), + config: Config{DefaultAutopilot: defaultMode}, + } +} + +// TestDefaultAutopilotAppliedOnRegister verifies that new sessions receive the +// default autopilot mode when no per-session override exists. +func TestDefaultAutopilotAppliedOnRegister(t *testing.T) { + m := newTestManagerWithDefault(model.AutopilotYolo) + m.RegisterSession("s1", "/path", "default") + + sessions := m.GetSessions() + if sessions[0].AutopilotMode != model.AutopilotYolo { + t.Errorf("autopilot = %q, want yolo (default)", sessions[0].AutopilotMode) + } +} + +// TestPersistedAutopilotOverridesDefault verifies that a persisted per-session +// mode takes priority over the daemon-wide default. +func TestPersistedAutopilotOverridesDefault(t *testing.T) { + m := newTestManagerWithDefault(model.AutopilotYolo) + m.autopilot["s1"] = model.AutopilotOn // persisted override + m.RegisterSession("s1", "/path", "default") + + sessions := m.GetSessions() + if sessions[0].AutopilotMode != model.AutopilotOn { + t.Errorf("autopilot = %q, want on (persisted should override default yolo)", sessions[0].AutopilotMode) + } +} + +// TestSetDefaultAutopilotPersistsToDisk verifies that SetDefaultAutopilot +// writes to disk and is readable by a new manager loaded from the same dir. +func TestSetDefaultAutopilotPersistsToDisk(t *testing.T) { + dir := t.TempDir() + m := NewWithDir(dir) + m.SetDefaultAutopilot(model.AutopilotYolo) + + // New manager from same dir should load the persisted default. + m2 := NewWithDir(dir) + if got := m2.GetDefaultAutopilot(); got != model.AutopilotYolo { + t.Errorf("GetDefaultAutopilot = %q, want yolo", got) + } +} + +// TestDefaultAutopilotAppliedOnScanner verifies that scanner-discovered +// sessions also receive the default autopilot mode. +func TestDefaultAutopilotAppliedOnScanner(t *testing.T) { + m := newTestManagerWithDefault(model.AutopilotOn) + s := &model.Session{SessionID: "scan-1", CWD: "/path", State: model.StateRunning, PID: 1} + m.UpdateSessionFromScanner(s) + + sessions := m.GetSessions() + if sessions[0].AutopilotMode != model.AutopilotOn { + t.Errorf("scanner session autopilot = %q, want on (default)", sessions[0].AutopilotMode) + } +} + +// TestDefaultNotAppliedWhenSessionAlreadyRegistered verifies that when a +// session is first registered via hook (getting the default), a subsequent +// scanner update does not clobber the mode. +func TestDefaultNotAppliedWhenSessionAlreadyRegistered(t *testing.T) { + m := newTestManagerWithDefault(model.AutopilotOn) + // Hook registers the session; default is applied. + m.RegisterSession("s1", "/path", "default") + + // User later cycles to yolo. + m.CycleAutopilot("s1") // on → yolo (since default applied "on" first) + // Actually start from registered state: default=on → cycle → yolo. + sessions := m.GetSessions() + if sessions[0].AutopilotMode != model.AutopilotYolo { + t.Fatalf("after cycle: want yolo, got %q", sessions[0].AutopilotMode) + } + + // Now scanner update comes in for the existing session — should NOT reset mode. + s := &model.Session{SessionID: "s1", CWD: "/scanner/path", State: model.StateRunning, PID: 999} + m.UpdateSessionFromScanner(s) + + sessions = m.GetSessions() + if sessions[0].AutopilotMode != model.AutopilotYolo { + t.Errorf("scanner update clobbered autopilot: got %q, want yolo", sessions[0].AutopilotMode) + } +} + +// TestGetSetDefaultAutopilot verifies the get/set round-trip in-memory. +func TestGetSetDefaultAutopilot(t *testing.T) { + m := newTestManager() + + if got := m.GetDefaultAutopilot(); got != "" { + t.Errorf("initial default = %q, want empty", got) + } + + m.SetDefaultAutopilot(model.AutopilotOn) + if got := m.GetDefaultAutopilot(); got != model.AutopilotOn { + t.Errorf("after set: default = %q, want on", got) + } + + m.SetDefaultAutopilot("") + if got := m.GetDefaultAutopilot(); got != "" { + t.Errorf("after clear: default = %q, want empty", got) + } +} diff --git a/tui/internal/client/client.go b/tui/internal/client/client.go index 8719eb8..dfa67bd 100644 --- a/tui/internal/client/client.go +++ b/tui/internal/client/client.go @@ -111,20 +111,22 @@ type PREvent struct { // serverEvent is the shape of NDJSON messages from the daemon. type serverEvent struct { - Event string `json:"event,omitempty"` - Sessions []Session `json:"sessions,omitempty"` - PRs []TrackedPR `json:"prs,omitempty"` - OK *bool `json:"ok,omitempty"` - NewRepo bool `json:"new_repo,omitempty"` + Event string `json:"event,omitempty"` + Sessions []Session `json:"sessions,omitempty"` + PRs []TrackedPR `json:"prs,omitempty"` + OK *bool `json:"ok,omitempty"` + NewRepo bool `json:"new_repo,omitempty"` + DefaultAutopilot string `json:"default_autopilot,omitempty"` } // request is the shape of NDJSON messages sent to the daemon. type request struct { - Action string `json:"action"` - SessionID string `json:"session_id,omitempty"` - PRURL string `json:"pr_url,omitempty"` - PRKey string `json:"pr_key,omitempty"` - MergeMethod string `json:"merge_method,omitempty"` + Action string `json:"action"` + SessionID string `json:"session_id,omitempty"` + PRURL string `json:"pr_url,omitempty"` + PRKey string `json:"pr_key,omitempty"` + MergeMethod string `json:"merge_method,omitempty"` + DefaultAutopilot string `json:"default_autopilot,omitempty"` } // Client manages the connection to the CSM daemon. @@ -140,10 +142,11 @@ func New(socketPath string) *Client { return &Client{socketPath: socketPath} } -// StateUpdate carries both sessions and PRs from a subscribe event. +// StateUpdate carries sessions, PRs, and global config from a subscribe event. type StateUpdate struct { - Sessions []Session - PRs []TrackedPR + Sessions []Session + PRs []TrackedPR + DefaultAutopilot string } // Subscribe connects to the daemon and streams state updates. @@ -177,7 +180,7 @@ func (c *Client) Subscribe() (<-chan StateUpdate, error) { continue } if ev.Event == "state_updated" || ev.Event == "sessions_updated" { - ch <- StateUpdate{Sessions: ev.Sessions, PRs: ev.PRs} + ch <- StateUpdate{Sessions: ev.Sessions, PRs: ev.PRs, DefaultAutopilot: ev.DefaultAutopilot} } } }() @@ -278,6 +281,23 @@ func (c *Client) TogglePRReview(key string) error { return err } +// SetDefaultAutopilot sets the daemon-wide default autopilot mode. +// Valid values: "" (none/off), "on", "yolo". +func (c *Client) SetDefaultAutopilot(mode string) error { + _, err := c.sendCommand(request{Action: "set_default_autopilot", DefaultAutopilot: mode}) + return err +} + +// GetConfig returns the current daemon configuration. Currently returns the +// default autopilot mode string. +func (c *Client) GetConfig() (string, error) { + resp, err := c.sendCommand(request{Action: "get_config"}) + if err != nil { + return "", err + } + return resp.DefaultAutopilot, nil +} + // Focus focuses the Ghostty tab for the given session. func (c *Client) Focus(sessionID string) error { _, err := c.sendCommand(request{Action: "focus", SessionID: sessionID}) diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 3f272fe..0a0e42a 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -68,7 +68,10 @@ type Model struct { mergePickerVisible bool // merge method picker showing mergePickerPR *client.TrackedPR // PR being configured (for display) mergePickerPRKey string // "owner/repo#N" — used for SetMergeMethod - scrollOffset int // scroll position in zoom body + scrollOffset int // scroll position in zoom body + // defaultAutopilot is the global default autopilot mode for new sessions. + // Values: "" (off), "on" (AUTO), "yolo" (YOLO). + defaultAutopilot string } // NewModel creates a new TUI model. @@ -183,6 +186,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case stateMsg: m.sessions = msg.Sessions m.prs = msg.PRs + m.defaultAutopilot = msg.DefaultAutopilot // Restore selection by ID to prevent jumping. totalItems := len(m.sessions) + len(m.prs) found := false @@ -576,6 +580,28 @@ end tell`, tabIdx, tabIdx) } } + case "d": + // Cycle default autopilot mode for new sessions: "" → "on" → "yolo" → "". + switch m.defaultAutopilot { + case "": + m.defaultAutopilot = "on" + m.flash = "\u2699 default autopilot: AUTO" + m.flashStyle = lipgloss.NewStyle().Bold(true).Foreground(colorRunning) + case "on": + m.defaultAutopilot = "yolo" + m.flash = "\u26a0 default autopilot: YOLO" + m.flashStyle = lipgloss.NewStyle().Bold(true).Foreground(colorOrange) + default: + m.defaultAutopilot = "" + m.flash = "\u2022 default autopilot: OFF" + m.flashStyle = lipgloss.NewStyle().Foreground(colorDimFg) + } + mode := m.defaultAutopilot + return m, tea.Batch(clearFlashAfter(2*time.Second), func() tea.Msg { + err := m.client.SetDefaultAutopilot(mode) + return actionResultMsg{action: "set default autopilot", err: err} + }) + case "esc": if m.mergePickerVisible { m.mergePickerVisible = false @@ -739,7 +765,7 @@ func (m Model) View() string { failingPRs++ } } - statusLine = renderStatusBar(m.connected, m.sessions, m.prs, failingPRs, m.flash, m.flashStyle, w) + statusLine = renderStatusBar(m.connected, m.sessions, m.prs, failingPRs, m.flash, m.flashStyle, m.defaultAutopilot, w) } statusHeight := lipgloss.Height(statusLine) @@ -751,14 +777,14 @@ func (m Model) View() string { mainContent = renderQueue(m.sessions, w, remainingHeight) } else if isSession { if sel := m.selected(); sel != nil { - mainContent = renderZoom(*sel, w, remainingHeight, m.scrollOffset) + mainContent = renderZoom(*sel, w, remainingHeight, m.scrollOffset, m.defaultAutopilot) } else { - mainContent = renderEmptyState(w, remainingHeight) + mainContent = renderEmptyState(w, remainingHeight, m.defaultAutopilot) } } else if selPR := m.selectedPR(); selPR != nil { mainContent = renderPRZoom(*selPR, w, remainingHeight, m.scrollOffset) } else { - mainContent = renderEmptyState(w, remainingHeight) + mainContent = renderEmptyState(w, remainingHeight, m.defaultAutopilot) } var outputParts []string @@ -776,7 +802,7 @@ func (m Model) View() string { } // renderStatusBar renders the top status bar with branding, connection info, and flash. -func renderStatusBar(connected bool, sessions []client.Session, prs []client.TrackedPR, failingPRs int, flash string, flashStyle lipgloss.Style, width int) string { +func renderStatusBar(connected bool, sessions []client.Session, prs []client.TrackedPR, failingPRs int, flash string, flashStyle lipgloss.Style, defaultAutopilot string, width int) string { logo := lipgloss.NewStyle(). Bold(true). Foreground(colorAccent). @@ -920,7 +946,16 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra } } - left := logo + " " + connStatus + " " + sessionCount + prCount + prBreakdownStr + stateBreakdownStr + pendingStr + failingStr + // Default autopilot indicator — shown only when a default is set. + defaultAutopilotStr := "" + switch defaultAutopilot { + case "on": + defaultAutopilotStr = " " + styleAutopilotOn.Render("\u2699 default: AUTO") + case "yolo": + defaultAutopilotStr = " " + styleAutopilotWarn.Render("\u26a0 default: YOLO") + } + + left := logo + " " + connStatus + " " + sessionCount + prCount + prBreakdownStr + stateBreakdownStr + defaultAutopilotStr + pendingStr + failingStr // Flash message (action feedback). if flash != "" { @@ -945,7 +980,8 @@ func renderStatusBar(connected bool, sessions []client.Session, prs []client.Tra } // renderEmptyState renders a centered empty state when no sessions exist. -func renderEmptyState(width, height int) string { +// defaultAutopilot is used to conditionally show a first-run discoverability tip. +func renderEmptyState(width, height int, defaultAutopilot string) string { art := lipgloss.NewStyle(). Foreground(colorAccent). Bold(true). @@ -965,14 +1001,19 @@ func renderEmptyState(width, height int) string { Foreground(colorSubtle). Render("Start a Claude Code session to see it here") - block := lipgloss.JoinVertical(lipgloss.Center, - art, - "", - title, - subtitle, - "", - hint, - ) + var blockParts []string + blockParts = append(blockParts, art, "", title, subtitle, "", hint) + + // First-run tip: only when no default is set, gently suggest 'd'. + if defaultAutopilot == "" { + tip := lipgloss.NewStyle(). + Foreground(colorSubtle). + Italic(true). + Render("Tip: Press 'd' to set a default autopilot mode for all new sessions") + blockParts = append(blockParts, "", tip) + } + + block := lipgloss.JoinVertical(lipgloss.Center, blockParts...) return lipgloss.NewStyle(). Height(height). diff --git a/tui/internal/tui/hints.go b/tui/internal/tui/hints.go index 7e9376a..5118b2e 100644 --- a/tui/internal/tui/hints.go +++ b/tui/internal/tui/hints.go @@ -31,6 +31,7 @@ func renderHints(queueVisible bool, hasPending bool, isPRSelected bool, width in // Session-specific hints. keys = append(keys, hint{"Enter", "focus"}) keys = append(keys, hint{"a", "autopilot"}) + keys = append(keys, hint{"d", "default"}) if hasPending { keys = append(keys, hint{"y", "approve"}, hint{"n", "reject"}) } @@ -101,6 +102,7 @@ func renderHelp(width, height, scrollOffset int) string { {"", "Sessions"}, {"Enter", "Focus — switch to Ghostty tab"}, {"a", "Cycle autopilot: OFF → ON → YOLO"}, + {"d", "Cycle default autopilot for new sessions"}, {"y / n", "Approve / reject pending tool"}, {"A", "Approve all safe pending tools"}, {"Q", "Toggle approval queue"}, @@ -137,7 +139,8 @@ func renderHelp(width, height, scrollOffset int) string { autopilotInfo := lipgloss.NewStyle().Foreground(colorDimFg).Italic(true).Render( "Session: OFF → ON (safe auto) → YOLO (all, 10s grace for destructive)\n" + - "PR: OFF → AUTO (hammer CI + merge on approval) → YOLO (merge without review)") + "PR: OFF → AUTO (hammer CI + merge on approval) → YOLO (merge without review)\n" + + "Default autopilot ('d'): applies to new sessions only. Per-session overrides take precedence.") stateInfo := lipgloss.NewStyle().Foreground(colorDimFg).Render( "Sessions: \u25b6 running \u23f8 waiting \u2714 idle \u25cf stopped\n" + diff --git a/tui/internal/tui/zoom.go b/tui/internal/tui/zoom.go index 2d08d51..08f3dc9 100644 --- a/tui/internal/tui/zoom.go +++ b/tui/internal/tui/zoom.go @@ -10,7 +10,8 @@ import ( ) // renderZoom renders the session detail panel with fixed header + scrollable body. -func renderZoom(s client.Session, width, height int, scrollOffset int) string { +// defaultAutopilot is the global default mode so we can annotate badges accordingly. +func renderZoom(s client.Session, width, height int, scrollOffset int, defaultAutopilot string) string { if width < 10 || height < 4 { return "" } @@ -34,9 +35,17 @@ func renderZoom(s client.Session, width, height int, scrollOffset int) string { switch s.AutopilotMode { case "on": - line1 += " " + styleAutopilotOn.Render("\u2699 AUTO") + label := "\u2699 AUTO" + if s.AutopilotMode == defaultAutopilot { + label += " (default)" + } + line1 += " " + styleAutopilotOn.Render(label) case "yolo": - line1 += " " + styleAutopilotWarn.Render("\u26a0 YOLO") + label := "\u26a0 YOLO" + if s.AutopilotMode == defaultAutopilot { + label += " (default)" + } + line1 += " " + styleAutopilotWarn.Render(label) } if s.PermissionMode == "plan" { diff --git a/tui/internal/tui/zoom_test.go b/tui/internal/tui/zoom_test.go index 9173d26..d51002e 100644 --- a/tui/internal/tui/zoom_test.go +++ b/tui/internal/tui/zoom_test.go @@ -44,7 +44,7 @@ func TestRenderZoom_NeverExceedsHeight(t *testing.T) { {"very narrow wraps lines", 25, 15}, } { t.Run(tt.name, func(t *testing.T) { - out := renderZoom(s, tt.width, tt.height, 0) + out := renderZoom(s, tt.width, tt.height, 0, "") lines := strings.Split(out, "\n") if len(lines) > tt.height { t.Errorf("renderZoom produced %d lines, want <= %d", len(lines), tt.height) @@ -63,7 +63,7 @@ func TestRenderZoom_LongContent_ClipsCleanly(t *testing.T) { s.AutopilotMode = "yolo" width, height := 80, 15 - out := renderZoom(s, width, height, 0) + out := renderZoom(s, width, height, 0, "") lines := strings.Split(out, "\n") if len(lines) > height { t.Errorf("renderZoom with long content produced %d lines, want <= %d", len(lines), height) @@ -79,7 +79,7 @@ func TestRenderZoom_WithPendingTools(t *testing.T) { } width, height := 80, 12 - out := renderZoom(s, width, height, 0) + out := renderZoom(s, width, height, 0, "") lines := strings.Split(out, "\n") if len(lines) > height { t.Errorf("renderZoom with pending tools produced %d lines, want <= %d", len(lines), height) @@ -126,7 +126,7 @@ func TestRenderZoom_ManyActivities_ShowsOverflow(t *testing.T) { }) } - out := renderZoom(s, 100, 30, 0) + out := renderZoom(s, 100, 30, 0, "") // Should indicate there are older activities not shown. if !strings.Contains(out, "+") || !strings.Contains(out, "more") { t.Error("15 activities should show overflow indicator like '+7 more'") @@ -138,7 +138,7 @@ func TestRenderZoom_ManyActivities_ShowsOverflow(t *testing.T) { func TestRenderZoom_MinimumHeight(t *testing.T) { s := testSession() // Height=4 is the minimum — should not panic or produce empty output. - out := renderZoom(s, 80, 4, 0) + out := renderZoom(s, 80, 4, 0, "") if out == "" { t.Error("renderZoom at height=4 should produce output") } @@ -153,7 +153,7 @@ func TestRenderZoom_MinimumHeight(t *testing.T) { func TestRenderZoom_ScrollClamp(t *testing.T) { s := testSession() // Scroll offset way beyond content should not panic. - out := renderZoom(s, 80, 20, 9999) + out := renderZoom(s, 80, 20, 9999, "") if out == "" { t.Error("renderZoom with huge scroll offset should produce output") } @@ -164,7 +164,7 @@ func TestRenderZoom_ScrollClamp(t *testing.T) { func TestRenderZoom_ShowsPermissionMode(t *testing.T) { s := testSession() s.PermissionMode = "plan" - out := renderZoom(s, 100, 20, 0) + out := renderZoom(s, 100, 20, 0, "") if !strings.Contains(out, "PLAN") { t.Error("session with permission_mode=plan should show PLAN badge") } @@ -261,7 +261,7 @@ func TestRenderZoom_EmptySession(t *testing.T) { SessionID: "empty", State: "idle", } - out := renderZoom(s, 80, 20, 0) + out := renderZoom(s, 80, 20, 0, "") if out == "" { t.Error("empty session should still render") } @@ -277,7 +277,7 @@ func TestRenderZoom_NilLastActivity(t *testing.T) { PID: 123, LastActivity: nil, } - out := renderZoom(s, 80, 20, 0) + out := renderZoom(s, 80, 20, 0, "") if out == "" { t.Error("nil LastActivity should still render") } @@ -289,7 +289,7 @@ func TestRenderZoom_AutopilotModes(t *testing.T) { for _, mode := range []string{"off", "on", "yolo", ""} { s := testSession() s.AutopilotMode = mode - out := renderZoom(s, 100, 20, 0) + out := renderZoom(s, 100, 20, 0, "") if out == "" { t.Errorf("autopilot=%q: should produce output", mode) } @@ -312,7 +312,7 @@ func TestRenderZoom_StripsXMLFromActivities(t *testing.T) { LastActivity: &now, } - out := renderZoom(s, 100, 20, 0) + out := renderZoom(s, 100, 20, 0, "") // The CC internal message should be filtered out entirely. if strings.Contains(out, "") { t.Error("zoom should strip XML tags from LastText") }