Skip to content

Commit 243f567

Browse files
authored
Default autopilot mode for new sessions (d key) (#32)
* autoresearch: cycle 2 — wire Config into state.Manager with Get/SetDefaultAutopilot * autoresearch: cycle 3 — apply default autopilot in RegisterSession for new sessions * autoresearch: cycle 4 — apply default autopilot in UpdateSessionFromScanner, extract helper * autoresearch: cycles 5+6 — add set_default_autopilot and get_config actions to control server * autoresearch: cycle 7 — add SetDefaultAutopilot and GetConfig client methods * autoresearch: cycle 8 — include default_autopilot in state snapshot sent to TUI subscribers * autoresearch: cycle 10 — add tests for default autopilot behavior * autoresearch: add 'd' key binding to cycle default autopilot mode Cycles through "" → "on" → "yolo" → "" with appropriate flash feedback using existing styleAutopilotOn/styleAutopilotWarn styles. Includes TODO comment for wiring client.SetDefaultAutopilot once Agent B's changes land. * autoresearch: show default autopilot indicator in status bar When defaultAutopilot is "on" shows [⚙ default: AUTO] badge (green), when "yolo" shows [⚠ default: YOLO] badge (yellow). No indicator when no default is set — status bar stays clean. * autoresearch: annotate autopilot badge with '(default)' in zoom panel When a session's AutopilotMode matches the global defaultAutopilot, the zoom header badge shows '⚙ AUTO (default)' or '⚠ YOLO (default)' so users can distinguish daemon-inherited from manually-set modes. * autoresearch: add 'd default' hint to session hints bar Adds 'd default' hint after 'a autopilot' in the session hints bar so users discover the default autopilot feature. Not shown for PR view since PRs don't have a session default concept. * autoresearch: document 'd' key and default autopilot behavior in help overlay Adds 'd Cycle default autopilot for new sessions' to the Sessions section of the help overlay, and explanatory text clarifying that defaults apply only to new sessions and per-session overrides take precedence. * autoresearch: add first-run discoverability tip in empty state When no sessions exist and no default autopilot is configured, the empty state shows a subtle italic tip: "Press 'd' to set a default autopilot mode for all new sessions". Tip disappears once a default is set. * autoresearch: refine flash message styling for default autopilot toggle Uses inline text styles (no padding) for flash messages instead of badge styles to keep status bar flash clean: green bold for AUTO, orange bold for YOLO, dim for OFF. * autoresearch: fix zoom_test.go calls after renderZoom signature change All renderZoom calls in test file updated to pass the new defaultAutopilot string parameter. All tests pass cleanly. * autoresearch: finalize cycle log for agent-c default autopilot UX Complete 10-cycle autoresearch log for default autopilot TUI presentation: Model field, 'd' key binding, status bar indicator, zoom badge annotation, hints bar, help overlay, empty state tip, flash styling, build/test fixes. * autoresearch: cycle 1 — add Config struct and config load/save helpers * autoresearch: add defaultAutopilot field to Model struct Adds the defaultAutopilot string field to Model for tracking the global default autopilot mode for new sessions ("", "on", "yolo"). Includes TODO comment for wiring to StateUpdate.DefaultAutopilot post-merge. * Wire TUI default autopilot to daemon client - Remove TODO stubs: stateMsg now reads DefaultAutopilot from daemon - d key calls client.SetDefaultAutopilot() instead of local-only update * Remove .autoresearch artifacts from tracking
1 parent 124591e commit 243f567

9 files changed

Lines changed: 373 additions & 62 deletions

File tree

daemon/internal/ctlserver/handler.go

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,22 @@ import (
1515
)
1616

1717
type ctlRequest struct {
18-
Action string `json:"action"`
19-
SessionID string `json:"session_id,omitempty"`
20-
PRURL string `json:"pr_url,omitempty"` // for add_pr
21-
PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr, cycle_pr_autopilot, set_merge_method
22-
MergeMethod string `json:"merge_method,omitempty"` // for set_merge_method
18+
Action string `json:"action"`
19+
SessionID string `json:"session_id,omitempty"`
20+
PRURL string `json:"pr_url,omitempty"` // for add_pr
21+
PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr, cycle_pr_autopilot, set_merge_method
22+
MergeMethod string `json:"merge_method,omitempty"` // for set_merge_method
23+
DefaultAutopilot string `json:"default_autopilot,omitempty"` // for set_default_autopilot
2324
}
2425

2526
type ctlResponse struct {
26-
OK *bool `json:"ok,omitempty"`
27-
Sessions []model.Session `json:"sessions,omitempty"`
28-
PRs []pr.TrackedPR `json:"prs,omitempty"`
29-
Event string `json:"event,omitempty"`
30-
AutopilotMode string `json:"autopilot_mode,omitempty"`
31-
NewRepo bool `json:"new_repo,omitempty"` // true when add_pr is the first PR for this repo
27+
OK *bool `json:"ok,omitempty"`
28+
Sessions []model.Session `json:"sessions,omitempty"`
29+
PRs []pr.TrackedPR `json:"prs,omitempty"`
30+
Event string `json:"event,omitempty"`
31+
AutopilotMode string `json:"autopilot_mode,omitempty"`
32+
NewRepo bool `json:"new_repo,omitempty"` // true when add_pr is the first PR for this repo
33+
DefaultAutopilot string `json:"default_autopilot,omitempty"`
3234
}
3335

3436
type Handler struct {
@@ -76,6 +78,10 @@ func (h *Handler) Handle(conn net.Conn) {
7678
h.handleSetMergeMethod(conn, req.PRKey, req.MergeMethod)
7779
case "toggle_review":
7880
h.handleToggleReview(conn, req.PRKey)
81+
case "set_default_autopilot":
82+
h.handleSetDefaultAutopilot(conn, req.DefaultAutopilot)
83+
case "get_config":
84+
h.handleGetConfig(conn)
7985
}
8086
}
8187
}
@@ -102,8 +108,9 @@ func (h *Handler) handleSubscribe(conn net.Conn) {
102108

103109
func (h *Handler) stateSnapshot() ctlResponse {
104110
resp := ctlResponse{
105-
Event: "state_updated",
106-
Sessions: h.state.GetSessions(),
111+
Event: "state_updated",
112+
Sessions: h.state.GetSessions(),
113+
DefaultAutopilot: h.state.GetDefaultAutopilot(),
107114
}
108115
if h.prPoll != nil {
109116
resp.PRs = h.prPoll.GetAll()
@@ -274,6 +281,18 @@ func (h *Handler) handleToggleReview(conn net.Conn, key string) {
274281
writeJSON(conn, ctlResponse{OK: &ok})
275282
}
276283

284+
func (h *Handler) handleSetDefaultAutopilot(conn net.Conn, mode string) {
285+
h.state.SetDefaultAutopilot(mode)
286+
ok := true
287+
writeJSON(conn, ctlResponse{OK: &ok, DefaultAutopilot: mode})
288+
}
289+
290+
func (h *Handler) handleGetConfig(conn net.Conn) {
291+
mode := h.state.GetDefaultAutopilot()
292+
ok := true
293+
writeJSON(conn, ctlResponse{OK: &ok, DefaultAutopilot: mode})
294+
}
295+
277296
func writeJSON(conn net.Conn, v any) {
278297
data, _ := json.Marshal(v)
279298
data = append(data, '\n')

daemon/internal/state/config.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package state
2+
3+
import (
4+
"encoding/json"
5+
"log"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// Config holds daemon-wide configuration persisted to ~/.csm/config.json.
11+
type Config struct {
12+
// DefaultAutopilot is the autopilot mode applied to newly discovered
13+
// sessions that have no persisted per-session override.
14+
// Valid values: "" (none), "on", "yolo".
15+
DefaultAutopilot string `json:"default_autopilot"`
16+
}
17+
18+
// configPath returns the path to the config file (~/.csm/config.json).
19+
func configPath() string {
20+
home, err := os.UserHomeDir()
21+
if err != nil {
22+
return ""
23+
}
24+
return filepath.Join(home, ".csm", "config.json")
25+
}
26+
27+
// loadConfig reads ~/.csm/config.json and returns the parsed Config.
28+
// Missing file is treated as empty Config (zero value).
29+
func loadConfig(path string) Config {
30+
if path == "" {
31+
return Config{}
32+
}
33+
data, err := os.ReadFile(path)
34+
if err != nil {
35+
// File not found is normal on first run.
36+
return Config{}
37+
}
38+
var cfg Config
39+
if err := json.Unmarshal(data, &cfg); err != nil {
40+
log.Printf("state: failed to parse config %s: %v", path, err)
41+
return Config{}
42+
}
43+
return cfg
44+
}
45+
46+
// saveConfig writes cfg to the given path as JSON.
47+
func saveConfig(path string, cfg Config) {
48+
if path == "" {
49+
return
50+
}
51+
data, err := json.MarshalIndent(cfg, "", " ")
52+
if err != nil {
53+
log.Printf("state: failed to marshal config: %v", err)
54+
return
55+
}
56+
if err := os.WriteFile(path, data, 0o644); err != nil {
57+
log.Printf("state: failed to save config to %s: %v", path, err)
58+
}
59+
}

daemon/internal/state/state.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ type Manager struct {
4444
subMu sync.Mutex
4545

4646
autopilotPath string
47+
48+
// config holds daemon-wide settings (e.g., default autopilot mode).
49+
config Config
50+
// configFilePath is the path to ~/.csm/config.json (empty = no persistence).
51+
configFilePath string
4752
}
4853

4954
// New creates a new state Manager, loading persisted autopilot state.
@@ -61,6 +66,8 @@ func New() *Manager {
6166
_ = os.MkdirAll(dir, 0o755)
6267
m.autopilotPath = filepath.Join(dir, "autopilot.json")
6368
m.loadAutopilot()
69+
m.configFilePath = filepath.Join(dir, "config.json")
70+
m.config = loadConfig(m.configFilePath)
6471
}
6572

6673
return m
@@ -79,6 +86,8 @@ func NewWithDir(dir string) *Manager {
7986
_ = os.MkdirAll(dir, 0o755)
8087
m.autopilotPath = filepath.Join(dir, "autopilot.json")
8188
m.loadAutopilot()
89+
m.configFilePath = filepath.Join(dir, "config.json")
90+
m.config = loadConfig(m.configFilePath)
8291
}
8392
return m
8493
}
@@ -101,9 +110,12 @@ func (m *Manager) RegisterSession(sid, cwd, permMode string) {
101110
s.PermissionMode = permMode
102111
s.ProjectName = filepath.Base(cwd)
103112

104-
// Restore persisted autopilot state.
113+
// Restore persisted autopilot state; fall back to config default for new sessions.
105114
if mode, ok := m.autopilot[sid]; ok && mode != "" {
106115
s.AutopilotMode = mode
116+
} else if !exists && m.config.DefaultAutopilot != "" {
117+
s.AutopilotMode = m.config.DefaultAutopilot
118+
log.Printf("state: applied default autopilot %s to session %s", m.config.DefaultAutopilot, sid)
107119
}
108120

109121
m.notifySubscribers()
@@ -123,16 +135,32 @@ func (m *Manager) UnregisterSession(sid string) {
123135
m.notifySubscribers()
124136
}
125137

138+
// applyDefaultAutopilot sets the session's AutopilotMode to the configured
139+
// default when the session has no persisted per-session mode. Caller must
140+
// hold m.mu (write lock).
141+
func (m *Manager) applyDefaultAutopilot(s *model.Session) {
142+
if _, hasPersisted := m.autopilot[s.SessionID]; hasPersisted {
143+
// Per-session persisted state already handled separately; skip.
144+
return
145+
}
146+
if s.AutopilotMode == "" && m.config.DefaultAutopilot != "" {
147+
s.AutopilotMode = m.config.DefaultAutopilot
148+
log.Printf("state: applied default autopilot %s to session %s", m.config.DefaultAutopilot, s.SessionID)
149+
}
150+
}
151+
126152
// UpdateSessionFromScanner merges scanner-discovered session data.
127153
func (m *Manager) UpdateSessionFromScanner(s *model.Session) {
128154
m.mu.Lock()
129155
defer m.mu.Unlock()
130156

131157
existing, ok := m.sessions[s.SessionID]
132158
if !ok {
133-
// New session from scanner.
159+
// New session from scanner — restore persisted mode or apply default.
134160
if mode, okAP := m.autopilot[s.SessionID]; okAP && mode != "" {
135161
s.AutopilotMode = mode
162+
} else {
163+
m.applyDefaultAutopilot(s)
136164
}
137165
m.sessions[s.SessionID] = s
138166
m.notifySubscribers()
@@ -467,3 +495,24 @@ func (m *Manager) saveAutopilot() {
467495
log.Printf("failed to save autopilot state: %v", err)
468496
}
469497
}
498+
499+
// GetDefaultAutopilot returns the daemon-wide default autopilot mode.
500+
// Empty string means no default (sessions start with autopilot off).
501+
func (m *Manager) GetDefaultAutopilot() string {
502+
m.mu.RLock()
503+
defer m.mu.RUnlock()
504+
return m.config.DefaultAutopilot
505+
}
506+
507+
// SetDefaultAutopilot updates the daemon-wide default autopilot mode and
508+
// persists it to ~/.csm/config.json. Valid values: "" (none), "on", "yolo".
509+
func (m *Manager) SetDefaultAutopilot(mode string) {
510+
m.mu.Lock()
511+
m.config.DefaultAutopilot = mode
512+
cfgPath := m.configFilePath
513+
cfg := m.config
514+
m.mu.Unlock()
515+
516+
saveConfig(cfgPath, cfg)
517+
log.Printf("state: default autopilot set to %q", mode)
518+
}

daemon/internal/state/state_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,3 +858,114 @@ func TestResolvePendingClearsPendingTools(t *testing.T) {
858858
t.Error("HasDestructive should be false after resolve")
859859
}
860860
}
861+
862+
// --- Default autopilot ---
863+
864+
// newTestManagerWithDefault creates a Manager without disk persistence but
865+
// with a pre-configured default autopilot mode.
866+
func newTestManagerWithDefault(defaultMode string) *Manager {
867+
return &Manager{
868+
sessions: make(map[string]*model.Session),
869+
autopilot: make(map[string]string),
870+
pending: make(map[string]*model.PendingApproval),
871+
cooldowns: make(map[string]time.Time),
872+
config: Config{DefaultAutopilot: defaultMode},
873+
}
874+
}
875+
876+
// TestDefaultAutopilotAppliedOnRegister verifies that new sessions receive the
877+
// default autopilot mode when no per-session override exists.
878+
func TestDefaultAutopilotAppliedOnRegister(t *testing.T) {
879+
m := newTestManagerWithDefault(model.AutopilotYolo)
880+
m.RegisterSession("s1", "/path", "default")
881+
882+
sessions := m.GetSessions()
883+
if sessions[0].AutopilotMode != model.AutopilotYolo {
884+
t.Errorf("autopilot = %q, want yolo (default)", sessions[0].AutopilotMode)
885+
}
886+
}
887+
888+
// TestPersistedAutopilotOverridesDefault verifies that a persisted per-session
889+
// mode takes priority over the daemon-wide default.
890+
func TestPersistedAutopilotOverridesDefault(t *testing.T) {
891+
m := newTestManagerWithDefault(model.AutopilotYolo)
892+
m.autopilot["s1"] = model.AutopilotOn // persisted override
893+
m.RegisterSession("s1", "/path", "default")
894+
895+
sessions := m.GetSessions()
896+
if sessions[0].AutopilotMode != model.AutopilotOn {
897+
t.Errorf("autopilot = %q, want on (persisted should override default yolo)", sessions[0].AutopilotMode)
898+
}
899+
}
900+
901+
// TestSetDefaultAutopilotPersistsToDisk verifies that SetDefaultAutopilot
902+
// writes to disk and is readable by a new manager loaded from the same dir.
903+
func TestSetDefaultAutopilotPersistsToDisk(t *testing.T) {
904+
dir := t.TempDir()
905+
m := NewWithDir(dir)
906+
m.SetDefaultAutopilot(model.AutopilotYolo)
907+
908+
// New manager from same dir should load the persisted default.
909+
m2 := NewWithDir(dir)
910+
if got := m2.GetDefaultAutopilot(); got != model.AutopilotYolo {
911+
t.Errorf("GetDefaultAutopilot = %q, want yolo", got)
912+
}
913+
}
914+
915+
// TestDefaultAutopilotAppliedOnScanner verifies that scanner-discovered
916+
// sessions also receive the default autopilot mode.
917+
func TestDefaultAutopilotAppliedOnScanner(t *testing.T) {
918+
m := newTestManagerWithDefault(model.AutopilotOn)
919+
s := &model.Session{SessionID: "scan-1", CWD: "/path", State: model.StateRunning, PID: 1}
920+
m.UpdateSessionFromScanner(s)
921+
922+
sessions := m.GetSessions()
923+
if sessions[0].AutopilotMode != model.AutopilotOn {
924+
t.Errorf("scanner session autopilot = %q, want on (default)", sessions[0].AutopilotMode)
925+
}
926+
}
927+
928+
// TestDefaultNotAppliedWhenSessionAlreadyRegistered verifies that when a
929+
// session is first registered via hook (getting the default), a subsequent
930+
// scanner update does not clobber the mode.
931+
func TestDefaultNotAppliedWhenSessionAlreadyRegistered(t *testing.T) {
932+
m := newTestManagerWithDefault(model.AutopilotOn)
933+
// Hook registers the session; default is applied.
934+
m.RegisterSession("s1", "/path", "default")
935+
936+
// User later cycles to yolo.
937+
m.CycleAutopilot("s1") // on → yolo (since default applied "on" first)
938+
// Actually start from registered state: default=on → cycle → yolo.
939+
sessions := m.GetSessions()
940+
if sessions[0].AutopilotMode != model.AutopilotYolo {
941+
t.Fatalf("after cycle: want yolo, got %q", sessions[0].AutopilotMode)
942+
}
943+
944+
// Now scanner update comes in for the existing session — should NOT reset mode.
945+
s := &model.Session{SessionID: "s1", CWD: "/scanner/path", State: model.StateRunning, PID: 999}
946+
m.UpdateSessionFromScanner(s)
947+
948+
sessions = m.GetSessions()
949+
if sessions[0].AutopilotMode != model.AutopilotYolo {
950+
t.Errorf("scanner update clobbered autopilot: got %q, want yolo", sessions[0].AutopilotMode)
951+
}
952+
}
953+
954+
// TestGetSetDefaultAutopilot verifies the get/set round-trip in-memory.
955+
func TestGetSetDefaultAutopilot(t *testing.T) {
956+
m := newTestManager()
957+
958+
if got := m.GetDefaultAutopilot(); got != "" {
959+
t.Errorf("initial default = %q, want empty", got)
960+
}
961+
962+
m.SetDefaultAutopilot(model.AutopilotOn)
963+
if got := m.GetDefaultAutopilot(); got != model.AutopilotOn {
964+
t.Errorf("after set: default = %q, want on", got)
965+
}
966+
967+
m.SetDefaultAutopilot("")
968+
if got := m.GetDefaultAutopilot(); got != "" {
969+
t.Errorf("after clear: default = %q, want empty", got)
970+
}
971+
}

0 commit comments

Comments
 (0)