From 95b99eaaf97823515ce53b69f43c0966984ba047 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Tue, 7 Jul 2026 23:03:24 -0600 Subject: [PATCH 01/10] feat(tui): add cloud settings screen and dashboard entry --- internal/tui/model.go | 1 + internal/tui/model_test.go | 25 ++++++++ internal/tui/update.go | 43 ++++++++++++- internal/tui/update_test.go | 117 +++++++++++++++++++++++++++++++++++- internal/tui/view.go | 31 ++++++++-- internal/tui/view_test.go | 1 + 6 files changed, 208 insertions(+), 10 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 144240d5..733ec88a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -34,6 +34,7 @@ const ( ScreenSessions ScreenSessionDetail ScreenSetup + ScreenCloudSettings ) // ─── Custom Messages ───────────────────────────────────────────────────────── diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index d7b28bd1..bfcc868b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -86,6 +86,31 @@ func TestNewInitializesModelDefaults(t *testing.T) { } } +func TestScreenCloudSettingsConstant(t *testing.T) { + if ScreenCloudSettings != ScreenSetup+1 { + t.Fatalf("ScreenCloudSettings = %d, want %d (ScreenSetup+1)", ScreenCloudSettings, ScreenSetup+1) + } + + seen := map[Screen]bool{} + for _, s := range []Screen{ + ScreenDashboard, + ScreenSearch, + ScreenSearchResults, + ScreenRecent, + ScreenObservationDetail, + ScreenTimeline, + ScreenSessions, + ScreenSessionDetail, + ScreenSetup, + ScreenCloudSettings, + } { + if seen[s] { + t.Fatalf("screen constant %d is duplicated", s) + } + seen[s] = true + } +} + func TestInitReturnsCommand(t *testing.T) { m := New(newTestFixture(t).store, "") if cmd := m.Init(); cmd == nil { diff --git a/internal/tui/update.go b/internal/tui/update.go index 1c5cfe00..98e46c8c 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -170,6 +170,8 @@ func (m Model) handleKeyPress(key string) (tea.Model, tea.Cmd) { return m.handleSessionDetailKeys(key) case ScreenSetup: return m.handleSetupKeys(key) + case ScreenCloudSettings: + return m.handleCloudSettingsKeys(key) } return m, nil } @@ -181,9 +183,17 @@ var dashboardMenuItems = []string{ "Recent observations", "Browse sessions", "Setup agent plugin", + "Cloud sync settings", "Quit", } +var cloudSettingsMenuItems = []string{ + "Configure server", + "View status", + "Enroll projects", + "Back", +} + func (m Model) handleDashboardKeys(key string) (tea.Model, tea.Cmd) { switch key { case "up", "k": @@ -241,7 +251,12 @@ func (m Model) handleDashboardSelection() (tea.Model, tea.Cmd) { m.SetupInstalling = false m.SetupInstallingName = "" return m, nil - case 4: // Quit + case 4: // Cloud sync settings + m.PrevScreen = ScreenDashboard + m.Screen = ScreenCloudSettings + m.Cursor = 0 + return m, nil + case 5: // Quit return m, tea.Quit } return m, nil @@ -592,6 +607,32 @@ func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) { return m, nil } +// ─── Cloud Settings ────────────────────────────────────────────────────────── + +func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.Cursor > 0 { + m.Cursor-- + } + case "down", "j": + if m.Cursor < len(cloudSettingsMenuItems)-1 { + m.Cursor++ + } + case "enter", " ": + if m.Cursor == len(cloudSettingsMenuItems)-1 { // Back + m.Screen = ScreenDashboard + m.Cursor = 0 + return m, loadStats(m.store) + } + case "esc", "q": + m.Screen = ScreenDashboard + m.Cursor = 0 + return m, loadStats(m.store) + } + return m, nil +} + // ─── Helpers ───────────────────────────────────────────────────────────────── // refreshScreen returns the appropriate data-loading Cmd for a given screen. diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 3bb15de7..68fd1c8f 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -210,6 +210,116 @@ func TestHandleDashboardAndSearchKeyPaths(t *testing.T) { } } +func TestDashboardHasCloudSettingsMenuItem(t *testing.T) { + cloudIdx, quitIdx := -1, -1 + for i, item := range dashboardMenuItems { + if item == "Cloud sync settings" { + cloudIdx = i + } + if item == "Quit" { + quitIdx = i + } + } + if cloudIdx < 0 { + t.Fatal("dashboard menu is missing Cloud sync settings item") + } + if quitIdx < 0 { + t.Fatal("dashboard menu is missing Quit item") + } + if cloudIdx >= quitIdx { + t.Fatalf("Cloud sync settings (%d) must appear before Quit (%d)", cloudIdx, quitIdx) + } +} + +func TestCloudSettingsNavigation(t *testing.T) { + m := New(nil, "") + m.Cursor = 4 // Cloud sync settings + + updatedModel, _ := m.handleDashboardSelection() + updated := updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("enter on Cloud sync settings should open ScreenCloudSettings, got %v", updated.Screen) + } + if updated.Cursor != 0 { + t.Fatalf("cursor should reset to 0 on entering cloud settings, got %d", updated.Cursor) + } + + updatedModel, _ = updated.handleCloudSettingsKeys("esc") + updated = updatedModel.(Model) + if updated.Screen != ScreenDashboard { + t.Fatalf("esc from cloud settings should return to dashboard, got %v", updated.Screen) + } + + m = New(nil, "") + m.Screen = ScreenCloudSettings + updatedModel, _ = m.handleCloudSettingsKeys("q") + updated = updatedModel.(Model) + if updated.Screen != ScreenDashboard { + t.Fatalf("q from cloud settings should return to dashboard, got %v", updated.Screen) + } +} + +func TestCloudSettingsMenuNavigation(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudSettings + + updatedModel, _ := m.handleCloudSettingsKeys("down") + updated := updatedModel.(Model) + if updated.Cursor != 1 { + t.Fatalf("down should move cursor to 1, got %d", updated.Cursor) + } + + updatedModel, _ = updated.handleCloudSettingsKeys("j") + updated = updatedModel.(Model) + if updated.Cursor != 2 { + t.Fatalf("j should move cursor to 2, got %d", updated.Cursor) + } + + updatedModel, _ = updated.handleCloudSettingsKeys("down") + updated = updatedModel.(Model) + if updated.Cursor != 3 { + t.Fatalf("down should move cursor to 3, got %d", updated.Cursor) + } + + updatedModel, _ = updated.handleCloudSettingsKeys("down") + updated = updatedModel.(Model) + if updated.Cursor != 3 { + t.Fatalf("down at bottom should stay at 3, got %d", updated.Cursor) + } + + updatedModel, _ = updated.handleCloudSettingsKeys("up") + updated = updatedModel.(Model) + if updated.Cursor != 2 { + t.Fatalf("up should move cursor to 2, got %d", updated.Cursor) + } + + updatedModel, _ = updated.handleCloudSettingsKeys("k") + updated = updatedModel.(Model) + if updated.Cursor != 1 { + t.Fatalf("k should move cursor to 1, got %d", updated.Cursor) + } + + m = New(nil, "") + m.Screen = ScreenCloudSettings + updatedModel, _ = m.handleCloudSettingsKeys("up") + updated = updatedModel.(Model) + if updated.Cursor != 0 { + t.Fatalf("up at top should stay at 0, got %d", updated.Cursor) + } + + m = New(nil, "") + m.Screen = ScreenCloudSettings + m.Cursor = 3 // Back + updatedModel, cmd := m.handleCloudSettingsKeys("enter") + updated = updatedModel.(Model) + if updated.Screen != ScreenDashboard { + t.Fatalf("enter on Back should return to dashboard, got %v", updated.Screen) + } + if cmd == nil { + t.Fatal("enter on Back should refresh stats") + } +} + func TestHandleRecentTimelineSessionsAndDetailKeyPaths(t *testing.T) { fx := newTestFixture(t) m := New(fx.store, "") @@ -457,6 +567,7 @@ func TestHandleKeyPressRouterAndClearsError(t *testing.T) { ScreenSessions, ScreenSessionDetail, ScreenSetup, + ScreenCloudSettings, } { m.Screen = screen m.ErrorMsg = "old error" @@ -483,7 +594,7 @@ func TestHandleDashboardKeysAndSelectionRemainingBranches(t *testing.T) { t.Fatal("cursor should stay at bottom boundary") } - m.Cursor = 4 + m.Cursor = 5 _, cmd := m.handleDashboardKeys(" ") if cmd == nil { t.Fatal("space on quit item should return quit command") @@ -501,10 +612,10 @@ func TestHandleDashboardKeysAndSelectionRemainingBranches(t *testing.T) { t.Fatal("cursor 0 selection should open search") } - m.Cursor = 4 + m.Cursor = 5 _, cmd = m.handleDashboardSelection() if cmd == nil { - t.Fatal("cursor 4 selection should quit") + t.Fatal("cursor 5 selection should quit") } m.Cursor = 99 diff --git a/internal/tui/view.go b/internal/tui/view.go index bed46f53..825a9625 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -80,6 +80,8 @@ func (m Model) View() string { content = m.viewSessionDetail() case ScreenSetup: content = m.viewSetup() + case ScreenCloudSettings: + content = m.viewCloudSettings() default: content = "Unknown screen" } @@ -159,19 +161,36 @@ func (m Model) viewDashboard() string { // Menu b.WriteString(titleStyle.Render(" Actions")) b.WriteString("\n") + b.WriteString(renderMenu(dashboardMenuItems, m.Cursor)) - for i, item := range dashboardMenuItems { - if i == m.Cursor { + // Help + b.WriteString(helpStyle.Render("\n j/k navigate • enter select • s search • q quit")) + + return b.String() +} + +func (m Model) viewCloudSettings() string { + var b strings.Builder + + b.WriteString(headerStyle.Render(" Cloud sync settings")) + b.WriteString("\n\n") + b.WriteString(renderMenu(cloudSettingsMenuItems, m.Cursor)) + b.WriteString(helpStyle.Render("\n j/k navigate • enter select • esc/q back")) + + return b.String() +} + +// renderMenu renders a vertical list of selectable menu items with a cursor. +func renderMenu(items []string, cursor int) string { + var b strings.Builder + for i, item := range items { + if i == cursor { b.WriteString(menuSelectedStyle.Render("▸ " + item)) } else { b.WriteString(menuItemStyle.Render(" " + item)) } b.WriteString("\n") } - - // Help - b.WriteString(helpStyle.Render("\n j/k navigate • enter select • s search • q quit")) - return b.String() } diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 904e6b51..20b93691 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -358,6 +358,7 @@ func TestViewRouterCoversAllScreens(t *testing.T) { {screen: ScreenSessions, want: "Sessions"}, {screen: ScreenSessionDetail, want: "Session:"}, {screen: ScreenSetup, want: "Setup"}, + {screen: ScreenCloudSettings, want: "Cloud sync settings"}, } for _, tt := range tests { From 1e48b100ba4d8c0de0597743456bde4cb1494bbf Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Tue, 7 Jul 2026 23:03:43 -0600 Subject: [PATCH 02/10] docs(cloud): document cloud.json.token fallback policy --- docs/engram-cloud/troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engram-cloud/troubleshooting.md b/docs/engram-cloud/troubleshooting.md index f46272e2..d15e37a7 100644 --- a/docs/engram-cloud/troubleshooting.md +++ b/docs/engram-cloud/troubleshooting.md @@ -49,7 +49,7 @@ Cloud auth token is runtime config: export ENGRAM_CLOUD_TOKEN="your-token" ``` -The local `~/.engram/cloud.json` stores the server URL. The token is intentionally read from the environment. +The local `~/.engram/cloud.json` stores the server URL and may also store a `token` fallback. `ENGRAM_CLOUD_TOKEN` takes precedence over any token in `cloud.json`; if the env var is unset, Engram falls back to `cloud.json.token`. This fallback is intentional (issue #343) for use cases such as background autosync where exporting the env var on every shell is not practical. --- From 309da7ee49f74553a92d01fd0e0fa7bc6166f795 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Tue, 7 Jul 2026 23:23:43 -0600 Subject: [PATCH 03/10] feat(tui): add cloud config form scaffold and read-only token status --- internal/store/store.go | 5 ++ internal/store/store_test.go | 7 ++ internal/tui/cloud.go | 111 ++++++++++++++++++++++++++++ internal/tui/cloud_test.go | 138 +++++++++++++++++++++++++++++++++++ internal/tui/model.go | 60 +++++++++++++-- internal/tui/model_test.go | 47 ++++++++++++ internal/tui/update.go | 130 ++++++++++++++++++++++++++++++++- internal/tui/update_test.go | 72 ++++++++++++++++++ internal/tui/view.go | 55 ++++++++++++++ internal/tui/view_test.go | 34 +++++++++ 10 files changed, 653 insertions(+), 6 deletions(-) create mode 100644 internal/tui/cloud.go create mode 100644 internal/tui/cloud_test.go diff --git a/internal/store/store.go b/internal/store/store.go index 24037c4a..db1b1bbb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -483,6 +483,11 @@ func (s *Store) MaxObservationLength() int { return s.cfg.MaxObservationLength } +// DataDir returns the configured data directory for the store. +func (s *Store) DataDir() string { + return s.cfg.DataDir +} + // ─── Store ─────────────────────────────────────────────────────────────────── type Store struct { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 4155a74a..44e4f7ee 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1382,6 +1382,13 @@ func TestSessionObservationsAddPromptImportAndSyncChunks(t *testing.T) { } } +func TestStoreDataDir(t *testing.T) { + s := newTestStore(t) + if got := s.DataDir(); got == "" { + t.Fatal("DataDir should not be empty") + } +} + func TestStoreLocalSyncFoundationEnqueuesCoreMutations(t *testing.T) { s := newTestStore(t) diff --git a/internal/tui/cloud.go b/internal/tui/cloud.go new file mode 100644 index 00000000..4475289d --- /dev/null +++ b/internal/tui/cloud.go @@ -0,0 +1,111 @@ +package tui + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" +) + +const ( + cloudConfigFileName = "cloud.json" + cloudHealthPath = "/health" +) + +// Token source labels displayed on the Cloud Config screen. +const ( + TokenSourceEnv = "set via ENGRAM_CLOUD_TOKEN" + TokenSourceFile = "read from cloud.json" + TokenSourceNone = "not set" +) + +type tuiCloudConfig struct { + ServerURL string `json:"server_url"` + Token string `json:"token,omitempty"` +} + +func cloudConfigPath(dataDir string) string { + return filepath.Join(dataDir, cloudConfigFileName) +} + +func loadCloudConfig(dataDir string) (*tuiCloudConfig, error) { + path := cloudConfigPath(dataDir) + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &tuiCloudConfig{}, nil + } + return nil, err + } + var cc tuiCloudConfig + if err := json.Unmarshal(b, &cc); err != nil { + return nil, err + } + return &cc, nil +} + +func saveCloudConfig(dataDir, serverURL string) error { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return err + } + cc, err := loadCloudConfig(dataDir) + if err != nil { + return err + } + cc.ServerURL = serverURL + b, err := json.MarshalIndent(cc, "", " ") + if err != nil { + return err + } + return os.WriteFile(cloudConfigPath(dataDir), b, 0o644) +} + +func tokenSourceMessage(dataDir string) string { + if strings.TrimSpace(os.Getenv("ENGRAM_CLOUD_TOKEN")) != "" { + return TokenSourceEnv + } + cc, err := loadCloudConfig(dataDir) + if err == nil && strings.TrimSpace(cc.Token) != "" { + return TokenSourceFile + } + return TokenSourceNone +} + +func effectiveCloudToken(dataDir string) string { + if token := strings.TrimSpace(os.Getenv("ENGRAM_CLOUD_TOKEN")); token != "" { + return token + } + cc, err := loadCloudConfig(dataDir) + if err != nil { + return "" + } + return strings.TrimSpace(cc.Token) +} + +func validateCloudServerURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + parsed, err := url.ParseRequestURI(trimmed) + if err != nil { + return "", err + } + scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) + if scheme != "http" && scheme != "https" { + return "", fmt.Errorf("scheme must be http or https") + } + if strings.TrimSpace(parsed.Host) == "" || strings.TrimSpace(parsed.Hostname()) == "" { + return "", fmt.Errorf("host is required") + } + if strings.TrimSpace(parsed.RawQuery) != "" { + return "", fmt.Errorf("query is not allowed") + } + if strings.TrimSpace(parsed.Fragment) != "" { + return "", fmt.Errorf("fragment is not allowed") + } + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + + diff --git a/internal/tui/cloud_test.go b/internal/tui/cloud_test.go new file mode 100644 index 00000000..9698b2c4 --- /dev/null +++ b/internal/tui/cloud_test.go @@ -0,0 +1,138 @@ +package tui + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func writeCloudJSON(t *testing.T, dir, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "cloud.json"), []byte(content), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } +} + +func TestLoadCloudConfigReadsServerURLAndToken(t *testing.T) { + dir := t.TempDir() + writeCloudJSON(t, dir, `{"server_url":"https://cloud.example.com","token":"file-token"}`) + + cc, err := loadCloudConfig(dir) + if err != nil { + t.Fatalf("loadCloudConfig: %v", err) + } + if cc.ServerURL != "https://cloud.example.com" { + t.Fatalf("server_url = %q", cc.ServerURL) + } + if cc.Token != "file-token" { + t.Fatalf("token = %q", cc.Token) + } +} + +func TestLoadCloudConfigMissingReturnsEmpty(t *testing.T) { + dir := t.TempDir() + + cc, err := loadCloudConfig(dir) + if err != nil { + t.Fatalf("loadCloudConfig: %v", err) + } + if cc.ServerURL != "" || cc.Token != "" { + t.Fatalf("expected empty config, got %+v", cc) + } +} + +func TestTokenSourceEnvOverridesFile(t *testing.T) { + dir := t.TempDir() + writeCloudJSON(t, dir, `{"token":"file-token"}`) + + t.Setenv("ENGRAM_CLOUD_TOKEN", "env-token") + if got := tokenSourceMessage(dir); got != TokenSourceEnv { + t.Fatalf("source = %q, want %q", got, TokenSourceEnv) + } +} + +func TestTokenSourceFileFallback(t *testing.T) { + dir := t.TempDir() + writeCloudJSON(t, dir, `{"token":"file-token"}`) + + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + if got := tokenSourceMessage(dir); got != TokenSourceFile { + t.Fatalf("source = %q, want %q", got, TokenSourceFile) + } +} + +func TestTokenSourceNone(t *testing.T) { + dir := t.TempDir() + + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + if got := tokenSourceMessage(dir); got != TokenSourceNone { + t.Fatalf("source = %q, want %q", got, TokenSourceNone) + } +} + +func TestSaveCloudConfigWritesOnlyServerURLAndPreservesToken(t *testing.T) { + dir := t.TempDir() + writeCloudJSON(t, dir, `{"server_url":"https://old.example.com","token":"file-token"}`) + + if err := saveCloudConfig(dir, "https://new.example.com"); err != nil { + t.Fatalf("saveCloudConfig: %v", err) + } + + b, err := os.ReadFile(filepath.Join(dir, "cloud.json")) + if err != nil { + t.Fatalf("read cloud.json: %v", err) + } + if !bytes.Contains(b, []byte(`"server_url": "https://new.example.com"`)) { + t.Fatalf("server_url not updated in %s", string(b)) + } + if !bytes.Contains(b, []byte(`"token": "file-token"`)) { + t.Fatalf("token was not preserved in %s", string(b)) + } +} + +func TestSaveCloudConfigDoesNotWriteToken(t *testing.T) { + dir := t.TempDir() + + if err := saveCloudConfig(dir, "https://new.example.com"); err != nil { + t.Fatalf("saveCloudConfig: %v", err) + } + + b, err := os.ReadFile(filepath.Join(dir, "cloud.json")) + if err != nil { + t.Fatalf("read cloud.json: %v", err) + } + if bytes.Contains(b, []byte(`"token"`)) { + t.Fatalf("token must never be written by TUI, got %s", string(b)) + } +} + +func TestValidateCloudServerURL(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {"https ok", "https://cloud.example.com", "https://cloud.example.com", false}, + {"trims space", " https://cloud.example.com ", "https://cloud.example.com", false}, + {"missing scheme", "cloud.example.com", "", true}, + {"bad scheme", "ftp://cloud.example.com", "", true}, + {"missing host", "https://", "", true}, + {"query not allowed", "https://cloud.example.com?x=1", "", true}, + {"fragment not allowed", "https://cloud.example.com#x", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateCloudServerURL(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("validateCloudServerURL(%q) err = %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("validateCloudServerURL(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + + diff --git a/internal/tui/model.go b/internal/tui/model.go index 733ec88a..92342801 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -35,6 +35,15 @@ const ( ScreenSessionDetail ScreenSetup ScreenCloudSettings + ScreenCloudConfig +) + +// Cloud Config form focus positions. +const ( + cloudConfigFocusInput = iota + cloudConfigFocusTest + cloudConfigFocusSave + cloudConfigFocusCancel ) // ─── Custom Messages ───────────────────────────────────────────────────────── @@ -84,6 +93,17 @@ type setupInstallMsg struct { err error } +type cloudConfigLoadedMsg struct { + serverURL string + tokenSource string + err error +} + +type cloudPingMsg struct { + status string + err error +} + // ─── Model ─────────────────────────────────────────────────────────────────── type Model struct { @@ -141,6 +161,16 @@ type Model struct { SetupAllowlistApplied bool // true = allowlist was added successfully SetupAllowlistError string // error message if allowlist injection failed SetupSpinner spinner.Model + + // Cloud config + CloudConfigInput textinput.Model + CloudConfigServerURL string + CloudConfigTokenSource string + CloudConfigError string + CloudConfigFocus int + CloudConfigPingStatus string + CloudConfigSaving bool + CloudConfigTest bool // true when the current ping is a test, not a save } // New creates a new TUI model connected to the given store. @@ -150,16 +180,22 @@ func New(s *store.Store, version string) Model { ti.CharLimit = 256 ti.Width = 60 + ci := textinput.New() + ci.Placeholder = "https://cloud.example.com" + ci.CharLimit = 256 + ci.Width = 60 + sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = lipgloss.NewStyle().Foreground(colorLavender) return Model{ - store: s, - Version: version, - Screen: ScreenDashboard, - SearchInput: ti, - SetupSpinner: sp, + store: s, + Version: version, + Screen: ScreenDashboard, + SearchInput: ti, + CloudConfigInput: ci, + SetupSpinner: sp, } } @@ -238,3 +274,17 @@ func installAgent(agentName string) tea.Cmd { var installAgentFn = setup.Install var addClaudeCodeAllowlistFn = setup.AddClaudeCodeAllowlist + +func loadCloudConfigCmd(dataDir string) tea.Cmd { + return func() tea.Msg { + cc, err := loadCloudConfig(dataDir) + if err != nil { + return cloudConfigLoadedMsg{err: err} + } + return cloudConfigLoadedMsg{ + serverURL: cc.ServerURL, + tokenSource: tokenSourceMessage(dataDir), + err: nil, + } + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index bfcc868b..70c65bba 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2,6 +2,8 @@ package tui import ( "errors" + "os" + "path/filepath" "testing" "github.com/Gentleman-Programming/engram/internal/setup" @@ -111,6 +113,51 @@ func TestScreenCloudSettingsConstant(t *testing.T) { } } +func TestScreenCloudConfigConstant(t *testing.T) { + if ScreenCloudConfig != ScreenCloudSettings+1 { + t.Fatalf("ScreenCloudConfig = %d, want %d (ScreenCloudSettings+1)", ScreenCloudConfig, ScreenCloudSettings+1) + } + + seen := map[Screen]bool{} + for _, s := range []Screen{ + ScreenDashboard, + ScreenSearch, + ScreenSearchResults, + ScreenRecent, + ScreenObservationDetail, + ScreenTimeline, + ScreenSessions, + ScreenSessionDetail, + ScreenSetup, + ScreenCloudSettings, + ScreenCloudConfig, + } { + if seen[s] { + t.Fatalf("screen constant %d is duplicated", s) + } + seen[s] = true + } +} + +func TestLoadCloudConfigCommand(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cloud.json"), []byte(`{"server_url":"https://cloud.example.com","token":"file-token"}`), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } + + msg := loadCloudConfigCmd(dir)() + loaded, ok := msg.(cloudConfigLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if loaded.serverURL != "https://cloud.example.com" { + t.Fatalf("serverURL = %q", loaded.serverURL) + } +} + func TestInitReturnsCommand(t *testing.T) { m := New(newTestFixture(t).store, "") if cmd := m.Init(); cmd == nil { diff --git a/internal/tui/update.go b/internal/tui/update.go index 98e46c8c..bb4f1eca 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -27,6 +27,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.Screen == ScreenSearch && m.SearchInput.Focused() { return m.handleSearchInputKeys(msg) } + // If cloud config URL input is focused, let it handle typing + if m.Screen == ScreenCloudConfig && m.CloudConfigFocus == cloudConfigFocusInput && m.CloudConfigInput.Focused() { + return m.handleCloudConfigInputKeys(msg) + } return m.handleKeyPress(msg.String()) // ─── Data loaded messages ──────────────────────────────────────────── @@ -119,6 +123,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SetupDone = true return m, nil + case cloudConfigLoadedMsg: + if msg.err != nil { + m.CloudConfigError = msg.err.Error() + return m, nil + } + m.CloudConfigServerURL = msg.serverURL + m.CloudConfigTokenSource = msg.tokenSource + m.CloudConfigInput.SetValue(msg.serverURL) + m.CloudConfigInput.Focus() + return m, nil + case clipboardCopiedMsg: // Emit the OSC 52 sequence to stdout so the terminal copies the content, // set the feedback label, and schedule its removal after 2 seconds. @@ -172,6 +187,8 @@ func (m Model) handleKeyPress(key string) (tea.Model, tea.Cmd) { return m.handleSetupKeys(key) case ScreenCloudSettings: return m.handleCloudSettingsKeys(key) + case ScreenCloudConfig: + return m.handleCloudConfigKeys(key) } return m, nil } @@ -620,7 +637,20 @@ func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) { m.Cursor++ } case "enter", " ": - if m.Cursor == len(cloudSettingsMenuItems)-1 { // Back + switch m.Cursor { + case 0: // Configure server + m.PrevScreen = ScreenCloudSettings + m.Screen = ScreenCloudConfig + m.Cursor = 0 + m.CloudConfigFocus = cloudConfigFocusInput + m.CloudConfigError = "" + m.CloudConfigPingStatus = "" + m.CloudConfigSaving = false + m.CloudConfigTest = false + m.CloudConfigInput.SetValue("") + m.CloudConfigInput.Focus() + return m, loadCloudConfigCmd(m.store.DataDir()) + case len(cloudSettingsMenuItems) - 1: // Back m.Screen = ScreenDashboard m.Cursor = 0 return m, loadStats(m.store) @@ -633,6 +663,104 @@ func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) { return m, nil } +// ─── Cloud Config ──────────────────────────────────────────────────────────── + +func (m Model) handleCloudConfigInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "tab": + m.CloudConfigInput.Blur() + m.CloudConfigFocus = cloudConfigFocusTest + return m, nil + case "shift+tab": + m.CloudConfigInput.Blur() + m.CloudConfigFocus = cloudConfigFocusCancel + return m, nil + case "esc": + m.CloudConfigInput.Blur() + m.Screen = ScreenCloudSettings + m.CloudConfigFocus = cloudConfigFocusInput + return m, nil + case "enter": + m.CloudConfigInput.Blur() + m.CloudConfigFocus = cloudConfigFocusSave + return m, nil + } + + var cmd tea.Cmd + m.CloudConfigInput, cmd = m.CloudConfigInput.Update(msg) + return m, cmd +} + +func (m Model) handleCloudConfigKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "esc", "q": + m.CloudConfigInput.Blur() + m.Screen = ScreenCloudSettings + m.CloudConfigFocus = cloudConfigFocusInput + return m, nil + case "tab": + m.CloudConfigFocus = nextCloudConfigFocus(m.CloudConfigFocus) + m.CloudConfigInput.Blur() + if m.CloudConfigFocus == cloudConfigFocusInput { + m.CloudConfigInput.Focus() + } + return m, nil + case "shift+tab": + m.CloudConfigFocus = prevCloudConfigFocus(m.CloudConfigFocus) + m.CloudConfigInput.Blur() + if m.CloudConfigFocus == cloudConfigFocusInput { + m.CloudConfigInput.Focus() + } + return m, nil + case "up", "k": + m.CloudConfigFocus = prevCloudConfigFocus(m.CloudConfigFocus) + m.CloudConfigInput.Blur() + if m.CloudConfigFocus == cloudConfigFocusInput { + m.CloudConfigInput.Focus() + } + return m, nil + case "down", "j": + m.CloudConfigFocus = nextCloudConfigFocus(m.CloudConfigFocus) + m.CloudConfigInput.Blur() + if m.CloudConfigFocus == cloudConfigFocusInput { + m.CloudConfigInput.Focus() + } + return m, nil + case "enter", " ": + return m.activateCloudConfigFocus() + } + return m, nil +} + +func nextCloudConfigFocus(f int) int { + if f >= cloudConfigFocusCancel { + return cloudConfigFocusInput + } + return f + 1 +} + +func prevCloudConfigFocus(f int) int { + if f <= cloudConfigFocusInput { + return cloudConfigFocusCancel + } + return f - 1 +} + +func (m Model) activateCloudConfigFocus() (tea.Model, tea.Cmd) { + switch m.CloudConfigFocus { + case cloudConfigFocusInput: + m.CloudConfigInput.Blur() + m.CloudConfigFocus = cloudConfigFocusSave + return m, nil + case cloudConfigFocusCancel: + m.CloudConfigInput.Blur() + m.Screen = ScreenCloudSettings + m.CloudConfigFocus = cloudConfigFocusInput + return m, nil + } + return m, nil +} + // ─── Helpers ───────────────────────────────────────────────────────────────── // refreshScreen returns the appropriate data-loading Cmd for a given screen. diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 68fd1c8f..0f17bf1d 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -320,6 +320,77 @@ func TestCloudSettingsMenuNavigation(t *testing.T) { } } +func TestCloudConfigNavigation(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenCloudSettings + m.Cursor = 0 // Configure server + + updatedModel, cmd := m.handleCloudSettingsKeys("enter") + updated := updatedModel.(Model) + if updated.Screen != ScreenCloudConfig { + t.Fatalf("enter on Configure server should open ScreenCloudConfig, got %v", updated.Screen) + } + if updated.CloudConfigFocus != cloudConfigFocusInput { + t.Fatalf("focus = %d, want input", updated.CloudConfigFocus) + } + if !updated.CloudConfigInput.Focused() { + t.Fatal("input should be focused on entry") + } + if cmd == nil { + t.Fatal("enter on Configure server should load cloud config") + } + + updatedModel, _ = updated.handleCloudConfigKeys("esc") + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("esc from cloud config should return to settings, got %v", updated.Screen) + } +} + +func TestCloudConfigTabCyclesFocus(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudConfig + m.CloudConfigFocus = cloudConfigFocusInput + m.CloudConfigInput.Focus() + + updatedModel, _ := m.handleCloudConfigKeys("tab") + updated := updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusTest { + t.Fatalf("tab should move focus to test, got %d", updated.CloudConfigFocus) + } + if updated.CloudConfigInput.Focused() { + t.Fatal("input should blur when focus leaves") + } + + updatedModel, _ = updated.handleCloudConfigKeys("tab") + updated = updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusSave { + t.Fatalf("tab should move focus to save, got %d", updated.CloudConfigFocus) + } + + updatedModel, _ = updated.handleCloudConfigKeys("tab") + updated = updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusCancel { + t.Fatalf("tab should move focus to cancel, got %d", updated.CloudConfigFocus) + } + + updatedModel, _ = updated.handleCloudConfigKeys("tab") + updated = updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusInput { + t.Fatalf("tab should wrap to input, got %d", updated.CloudConfigFocus) + } + if !updated.CloudConfigInput.Focused() { + t.Fatal("input should focus when wrapping back") + } + + updatedModel, _ = updated.handleCloudConfigKeys("shift+tab") + updated = updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusCancel { + t.Fatalf("shift+tab should wrap backwards, got %d", updated.CloudConfigFocus) + } +} + func TestHandleRecentTimelineSessionsAndDetailKeyPaths(t *testing.T) { fx := newTestFixture(t) m := New(fx.store, "") @@ -568,6 +639,7 @@ func TestHandleKeyPressRouterAndClearsError(t *testing.T) { ScreenSessionDetail, ScreenSetup, ScreenCloudSettings, + ScreenCloudConfig, } { m.Screen = screen m.ErrorMsg = "old error" diff --git a/internal/tui/view.go b/internal/tui/view.go index 825a9625..28be82b7 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -82,6 +82,8 @@ func (m Model) View() string { content = m.viewSetup() case ScreenCloudSettings: content = m.viewCloudSettings() + case ScreenCloudConfig: + content = m.viewCloudConfig() default: content = "Unknown screen" } @@ -180,6 +182,59 @@ func (m Model) viewCloudSettings() string { return b.String() } +func (m Model) viewCloudConfig() string { + var b strings.Builder + + b.WriteString(headerStyle.Render(" Configure cloud server")) + b.WriteString("\n\n") + + b.WriteString(detailLabelStyle.Render("Server URL:")) + b.WriteString(" ") + if m.CloudConfigFocus == cloudConfigFocusInput { + b.WriteString(searchInputStyle.Render(m.CloudConfigInput.View())) + } else { + b.WriteString(detailValueStyle.Render(m.CloudConfigInput.View())) + } + b.WriteString("\n\n") + + b.WriteString(detailLabelStyle.Render("Token:")) + b.WriteString(" ") + b.WriteString(detailValueStyle.Render(m.CloudConfigTokenSource)) + b.WriteString("\n") + if m.CloudConfigTokenSource != TokenSourceEnv { + b.WriteString(timestampStyle.Render(" Set ENGRAM_CLOUD_TOKEN to override cloud.json.token")) + b.WriteString("\n") + } + b.WriteString("\n") + + buttons := []string{"[Test]", "[Save]", "[Cancel]"} + for i, label := range buttons { + focus := i + 1 // input is 0 + if focus == m.CloudConfigFocus { + b.WriteString(menuSelectedStyle.Render("▸ " + label)) + } else { + b.WriteString(menuItemStyle.Render(" " + label)) + } + b.WriteString(" ") + } + b.WriteString("\n") + + if m.CloudConfigSaving { + b.WriteString("\n") + b.WriteString(m.SetupSpinner.View()) + b.WriteString(" Pinging server...") + b.WriteString("\n") + } else if m.CloudConfigPingStatus != "" { + b.WriteString("\n") + b.WriteString(detailValueStyle.Render("Status: " + m.CloudConfigPingStatus)) + b.WriteString("\n") + } + + b.WriteString(helpStyle.Render("\n tab/shift+tab cycle • enter select • esc/q back")) + + return b.String() +} + // renderMenu renders a vertical list of selectable menu items with a cursor. func renderMenu(items []string, cursor int) string { var b strings.Builder diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 20b93691..fd74bd9d 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -75,6 +75,39 @@ func TestRenderObservationListItem(t *testing.T) { } } +func TestViewCloudConfigRendersTokenSource(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudConfig + m.CloudConfigServerURL = "https://cloud.example.com" + m.CloudConfigTokenSource = TokenSourceNone + + out := m.View() + if !strings.Contains(out, "Configure cloud server") { + t.Fatal("view should render screen title") + } + if !strings.Contains(out, "Server URL") { + t.Fatal("view should render server URL label") + } + if !strings.Contains(out, "Token:") { + t.Fatal("view should render token label") + } + if !strings.Contains(out, TokenSourceNone) { + t.Fatalf("view should render token source %q", TokenSourceNone) + } + + m.CloudConfigTokenSource = TokenSourceEnv + out = m.View() + if !strings.Contains(out, TokenSourceEnv) { + t.Fatalf("view should render env token source %q", TokenSourceEnv) + } + + m.CloudConfigTokenSource = TokenSourceFile + out = m.View() + if !strings.Contains(out, TokenSourceFile) { + t.Fatalf("view should render file token source %q", TokenSourceFile) + } +} + func TestViewRouterAndErrorRendering(t *testing.T) { m := New(nil, "") m.Screen = Screen(999) @@ -359,6 +392,7 @@ func TestViewRouterCoversAllScreens(t *testing.T) { {screen: ScreenSessionDetail, want: "Session:"}, {screen: ScreenSetup, want: "Setup"}, {screen: ScreenCloudSettings, want: "Cloud sync settings"}, + {screen: ScreenCloudConfig, want: "Configure cloud server"}, } for _, tt := range tests { From 19f780755354a1652eea1d85e78e4a375fdd51f5 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Tue, 7 Jul 2026 23:24:33 -0600 Subject: [PATCH 04/10] feat(tui): add ping command with injectable transport --- internal/tui/cloud.go | 45 ++++++++++++++++++++ internal/tui/cloud_test.go | 87 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/internal/tui/cloud.go b/internal/tui/cloud.go index 4475289d..b50c6dc2 100644 --- a/internal/tui/cloud.go +++ b/internal/tui/cloud.go @@ -3,10 +3,14 @@ package tui import ( "encoding/json" "fmt" + "net/http" "net/url" "os" "path/filepath" "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" ) const ( @@ -84,6 +88,47 @@ func effectiveCloudToken(dataDir string) string { return strings.TrimSpace(cc.Token) } +// pingCloudTransport can be overridden in tests to avoid real network calls. +var pingCloudTransport http.RoundTripper = http.DefaultTransport + +func pingCloudServer(serverURL, token string) tea.Cmd { + return func() tea.Msg { + status, err := pingCloudServerStatus(serverURL, token) + return cloudPingMsg{status: status, err: err} + } +} + +func pingCloudServerStatus(serverURL, token string) (string, error) { + validatedURL, err := validateCloudServerURL(serverURL) + if err != nil { + return "unreachable", err + } + + req, err := http.NewRequest(http.MethodGet, validatedURL+cloudHealthPath, nil) + if err != nil { + return "unreachable", err + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + client := &http.Client{Timeout: 3 * time.Second, Transport: pingCloudTransport} + resp, err := client.Do(req) + if err != nil { + return "unreachable", err + } + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusUnauthorized: + return "unauthorized", nil + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return "reachable", nil + default: + return "unreachable", fmt.Errorf("unexpected status %d", resp.StatusCode) + } +} + func validateCloudServerURL(raw string) (string, error) { trimmed := strings.TrimSpace(raw) parsed, err := url.ParseRequestURI(trimmed) diff --git a/internal/tui/cloud_test.go b/internal/tui/cloud_test.go index 9698b2c4..c125ad25 100644 --- a/internal/tui/cloud_test.go +++ b/internal/tui/cloud_test.go @@ -2,6 +2,9 @@ package tui import ( "bytes" + "errors" + "io" + "net/http" "os" "path/filepath" "testing" @@ -91,6 +94,90 @@ func TestSaveCloudConfigWritesOnlyServerURLAndPreservesToken(t *testing.T) { } } +func httpHandlerWithStatus(code int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + }) +} + +type fakePingTransport struct { + statusCode int + err error +} + +func (f *fakePingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if f.err != nil { + return nil, f.err + } + return &http.Response{ + StatusCode: f.statusCode, + Body: io.NopCloser(bytes.NewReader(nil)), + Request: req, + Header: make(http.Header), + }, nil +} + +func TestPingCloudServerReachable(t *testing.T) { + orig := pingCloudTransport + pingCloudTransport = &fakePingTransport{statusCode: http.StatusOK} + defer func() { pingCloudTransport = orig }() + + msg := pingCloudServer("https://cloud.example.com", "token")().(cloudPingMsg) + if msg.status != "reachable" { + t.Fatalf("status = %q, want reachable", msg.status) + } + if msg.err != nil { + t.Fatalf("unexpected err: %v", msg.err) + } +} + +func TestPingCloudServerUnauthorized(t *testing.T) { + orig := pingCloudTransport + pingCloudTransport = &fakePingTransport{statusCode: http.StatusUnauthorized} + defer func() { pingCloudTransport = orig }() + + msg := pingCloudServer("https://cloud.example.com", "token")().(cloudPingMsg) + if msg.status != "unauthorized" { + t.Fatalf("status = %q, want unauthorized", msg.status) + } +} + +func TestPingCloudServerUnreachable(t *testing.T) { + orig := pingCloudTransport + pingCloudTransport = &fakePingTransport{err: errors.New("connection refused")} + defer func() { pingCloudTransport = orig }() + + msg := pingCloudServer("https://cloud.example.com", "token")().(cloudPingMsg) + if msg.status != "unreachable" { + t.Fatalf("status = %q, want unreachable", msg.status) + } + if msg.err == nil { + t.Fatal("expected error for unreachable server") + } +} + +func TestPingCloudServer5xxIsUnreachable(t *testing.T) { + orig := pingCloudTransport + pingCloudTransport = &fakePingTransport{statusCode: http.StatusServiceUnavailable} + defer func() { pingCloudTransport = orig }() + + msg := pingCloudServer("https://cloud.example.com", "token")().(cloudPingMsg) + if msg.status != "unreachable" { + t.Fatalf("status = %q, want unreachable", msg.status) + } +} + +func TestPingCloudServerMalformedURL(t *testing.T) { + orig := pingCloudTransport + pingCloudTransport = &fakePingTransport{} + defer func() { pingCloudTransport = orig }() + + msg := pingCloudServer("not a url", "token")().(cloudPingMsg) + if msg.status != "unreachable" { + t.Fatalf("status = %q, want unreachable", msg.status) + } +} + func TestSaveCloudConfigDoesNotWriteToken(t *testing.T) { dir := t.TempDir() From 6200b801c9795611c7886f9a69ba50676b9123e8 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Tue, 7 Jul 2026 23:25:43 -0600 Subject: [PATCH 05/10] feat(tui): wire ping result to cloud config save flow --- internal/tui/update.go | 46 ++++++++++ internal/tui/update_test.go | 165 ++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/internal/tui/update.go b/internal/tui/update.go index bb4f1eca..7c152c85 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -134,6 +134,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.CloudConfigInput.Focus() return m, nil + case cloudPingMsg: + m.CloudConfigSaving = false + m.CloudConfigPingStatus = msg.status + if msg.err != nil { + m.CloudConfigError = msg.err.Error() + return m, nil + } + if m.CloudConfigTest { + // Test-only ping: just show the status. + return m, nil + } + if msg.status == "reachable" || msg.status == "unauthorized" { + validatedURL, err := validateCloudServerURL(m.CloudConfigInput.Value()) + if err != nil { + m.CloudConfigError = err.Error() + return m, nil + } + if err := saveCloudConfig(m.store.DataDir(), validatedURL); err != nil { + m.CloudConfigError = err.Error() + return m, nil + } + m.Screen = ScreenCloudSettings + m.Cursor = 0 + m.CloudConfigFocus = cloudConfigFocusInput + return m, nil + } + m.CloudConfigError = "server is unreachable" + return m, nil + case clipboardCopiedMsg: // Emit the OSC 52 sequence to stdout so the terminal copies the content, // set the feedback label, and schedule its removal after 2 seconds. @@ -752,6 +781,10 @@ func (m Model) activateCloudConfigFocus() (tea.Model, tea.Cmd) { m.CloudConfigInput.Blur() m.CloudConfigFocus = cloudConfigFocusSave return m, nil + case cloudConfigFocusTest: + return m.runCloudConfigPing(true) + case cloudConfigFocusSave: + return m.runCloudConfigPing(false) case cloudConfigFocusCancel: m.CloudConfigInput.Blur() m.Screen = ScreenCloudSettings @@ -761,6 +794,19 @@ func (m Model) activateCloudConfigFocus() (tea.Model, tea.Cmd) { return m, nil } +func (m Model) runCloudConfigPing(testOnly bool) (tea.Model, tea.Cmd) { + m.CloudConfigError = "" + m.CloudConfigPingStatus = "" + validatedURL, err := validateCloudServerURL(m.CloudConfigInput.Value()) + if err != nil { + m.CloudConfigError = err.Error() + return m, nil + } + m.CloudConfigTest = testOnly + m.CloudConfigSaving = !testOnly + return m, pingCloudServer(validatedURL, effectiveCloudToken(m.store.DataDir())) +} + // ─── Helpers ───────────────────────────────────────────────────────────────── // refreshScreen returns the appropriate data-loading Cmd for a given screen. diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 0f17bf1d..0fa14841 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1,7 +1,11 @@ package tui import ( + "bytes" "errors" + "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/Gentleman-Programming/engram/internal/setup" @@ -391,6 +395,167 @@ func TestCloudConfigTabCyclesFocus(t *testing.T) { } } +func TestCloudConfigSaveValidURLPersists(t *testing.T) { + fx := newTestFixture(t) + server := httptest.NewServer(httpHandlerWithStatus(200)) + defer server.Close() + + m := New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigInput.SetValue(server.URL) + m.CloudConfigFocus = cloudConfigFocusSave + + updatedModel, cmd := m.handleCloudConfigKeys("enter") + updated := updatedModel.(Model) + if !updated.CloudConfigSaving { + t.Fatal("save should set saving state") + } + + ping := cmd() + updatedModel, _ = updated.Update(ping) + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("reachable save should return to settings, got %v", updated.Screen) + } + + b, err := os.ReadFile(filepath.Join(fx.store.DataDir(), "cloud.json")) + if err != nil { + t.Fatalf("read cloud.json: %v", err) + } + if !bytes.Contains(b, []byte(server.URL)) { + t.Fatalf("cloud.json should contain saved URL, got %s", string(b)) + } +} + +func TestCloudConfigSaveInvalidURLNoPersist(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigInput.SetValue("not a url") + m.CloudConfigFocus = cloudConfigFocusSave + + updatedModel, cmd := m.handleCloudConfigKeys("enter") + updated := updatedModel.(Model) + if updated.CloudConfigError == "" { + t.Fatal("malformed URL should set inline error") + } + if cmd != nil { + t.Fatal("malformed URL should not fire ping") + } + + if _, err := os.Stat(filepath.Join(fx.store.DataDir(), "cloud.json")); err == nil { + t.Fatal("cloud.json should not be created for invalid URL") + } +} + +func TestCloudConfigSaveUnreachableNoPersist(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigInput.SetValue("https://localhost:1") + m.CloudConfigFocus = cloudConfigFocusSave + + orig := pingCloudTransport + pingCloudTransport = &fakePingTransport{err: errors.New("connection refused")} + defer func() { pingCloudTransport = orig }() + + updatedModel, cmd := m.handleCloudConfigKeys("enter") + updated := updatedModel.(Model) + if !updated.CloudConfigSaving { + t.Fatal("save should set saving state") + } + + updatedModel, _ = updated.Update(cmd()) + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudConfig { + t.Fatalf("unreachable should stay on form, got %v", updated.Screen) + } + if updated.CloudConfigError == "" { + t.Fatal("unreachable should set error") + } + + if _, err := os.Stat(filepath.Join(fx.store.DataDir(), "cloud.json")); err == nil { + t.Fatal("cloud.json should not be created for unreachable server") + } +} + +func TestCloudConfigSaveUnauthorizedPersists(t *testing.T) { + fx := newTestFixture(t) + server := httptest.NewServer(httpHandlerWithStatus(401)) + defer server.Close() + + m := New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigInput.SetValue(server.URL) + m.CloudConfigFocus = cloudConfigFocusSave + + updatedModel, cmd := m.handleCloudConfigKeys("enter") + updated := updatedModel.(Model) + ping := cmd() + updatedModel, _ = updated.Update(ping) + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("unauthorized save should still return to settings, got %v", updated.Screen) + } + + b, err := os.ReadFile(filepath.Join(fx.store.DataDir(), "cloud.json")) + if err != nil { + t.Fatalf("read cloud.json: %v", err) + } + if !bytes.Contains(b, []byte(server.URL)) { + t.Fatalf("cloud.json should contain saved URL after 401, got %s", string(b)) + } +} + +func TestCloudConfigSavePreservesToken(t *testing.T) { + fx := newTestFixture(t) + server := httptest.NewServer(httpHandlerWithStatus(200)) + defer server.Close() + + if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`{"server_url":"https://old.example.com","token":"existing-token"}`), 0o644); err != nil { + t.Fatalf("seed cloud.json: %v", err) + } + + m := New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigInput.SetValue(server.URL) + m.CloudConfigFocus = cloudConfigFocusSave + + updatedModel, cmd := m.handleCloudConfigKeys("enter") + updated := updatedModel.(Model) + updatedModel, _ = updated.Update(cmd()) + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("save should return to settings, got %v", updated.Screen) + } + + b, err := os.ReadFile(filepath.Join(fx.store.DataDir(), "cloud.json")) + if err != nil { + t.Fatalf("read cloud.json: %v", err) + } + if !bytes.Contains(b, []byte(`"token": "existing-token"`)) { + t.Fatalf("existing token must be preserved, got %s", string(b)) + } +} + +func TestCloudConfigSaveShowsSpinnerDuringPing(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigInput.SetValue("https://cloud.example.com") + m.CloudConfigFocus = cloudConfigFocusSave + + orig := pingCloudTransport + pingCloudTransport = &fakePingTransport{err: errors.New("connection refused")} + defer func() { pingCloudTransport = orig }() + + updatedModel, _ := m.handleCloudConfigKeys("enter") + updated := updatedModel.(Model) + if !updated.CloudConfigSaving { + t.Fatal("CloudConfigSaving should be true during async ping") + } +} + func TestHandleRecentTimelineSessionsAndDetailKeyPaths(t *testing.T) { fx := newTestFixture(t) m := New(fx.store, "") From 84bfeff83a064bae9a8d8e30b5da1a963b564b23 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Wed, 8 Jul 2026 00:01:04 -0600 Subject: [PATCH 06/10] feat(tui): add cloud status view --- internal/store/store.go | 19 +++++++ internal/store/store_test.go | 46 ++++++++++++++++ internal/tui/model.go | 47 ++++++++++++++++ internal/tui/model_test.go | 104 +++++++++++++++++++++++++++++++++++ internal/tui/update.go | 54 ++++++++++++++++++ internal/tui/update_test.go | 85 ++++++++++++++++++++++++++++ internal/tui/view.go | 46 ++++++++++++++++ internal/tui/view_test.go | 78 ++++++++++++++++++++++++++ 8 files changed, 479 insertions(+) diff --git a/internal/store/store.go b/internal/store/store.go index db1b1bbb..f983aff0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3766,6 +3766,25 @@ func (s *Store) CountPendingNonEnrolledSyncMutations(targetKey string) ([]Pendin return counts, rows.Err() } +// CountPendingSyncMutations returns the number of unacknowledged mutations for +// the given target that are currently eligible to sync (global/project-empty or +// enrolled projects). +func (s *Store) CountPendingSyncMutations(targetKey string) (int64, error) { + targetKey = normalizeSyncTargetKey(targetKey) + var count int64 + row := s.db.QueryRow(` + SELECT COUNT(*) + FROM sync_mutations sm + LEFT JOIN sync_enrolled_projects sep ON sm.project = sep.project + WHERE sm.target_key = ? AND sm.acked_at IS NULL + AND (sm.project = '' OR sep.project IS NOT NULL) + `, targetKey) + if err := row.Scan(&count); err != nil { + return 0, err + } + return count, nil +} + // SkipAckNonEnrolledMutations acks (marks as skipped) all pending mutations // that belong to non-enrolled projects, preventing journal bloat. Empty-project // mutations are never skipped — they always sync regardless of enrollment. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 44e4f7ee..6f6e6448 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1389,6 +1389,52 @@ func TestStoreDataDir(t *testing.T) { } } +func TestCountPendingSyncMutations(t *testing.T) { + s := newTestStore(t) + + count, err := s.CountPendingSyncMutations(DefaultSyncTargetKey) + if err != nil { + t.Fatalf("CountPendingSyncMutations empty: %v", err) + } + if count != 0 { + t.Fatalf("count = %d, want 0", count) + } + + if err := s.EnrollProject("engram"); err != nil { + t.Fatalf("enroll: %v", err) + } + if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey) + if err != nil { + t.Fatalf("CountPendingSyncMutations after session: %v", err) + } + if count != 1 { + t.Fatalf("count = %d, want 1", count) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "bugfix", + Title: "one", + Content: "content", + Project: "engram", + Scope: "project", + }); err != nil { + t.Fatalf("add observation: %v", err) + } + + count, err = s.CountPendingSyncMutations(DefaultSyncTargetKey) + if err != nil { + t.Fatalf("CountPendingSyncMutations after observation: %v", err) + } + if count != 2 { + t.Fatalf("count = %d, want 2", count) + } +} + func TestStoreLocalSyncFoundationEnqueuesCoreMutations(t *testing.T) { s := newTestStore(t) diff --git a/internal/tui/model.go b/internal/tui/model.go index 92342801..90a67a2c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -36,6 +36,7 @@ const ( ScreenSetup ScreenCloudSettings ScreenCloudConfig + ScreenCloudStatus ) // Cloud Config form focus positions. @@ -104,6 +105,15 @@ type cloudPingMsg struct { err error } +type cloudStatusLoadedMsg struct { + serverURL string + tokenSource string + lastSync string + pendingCount int64 + lastError string + err error +} + // ─── Model ─────────────────────────────────────────────────────────────────── type Model struct { @@ -171,6 +181,15 @@ type Model struct { CloudConfigPingStatus string CloudConfigSaving bool CloudConfigTest bool // true when the current ping is a test, not a save + + // Cloud status + CloudStatusServerURL string + CloudStatusTokenSource string + CloudStatusHealth string + CloudStatusLastSync string + CloudStatusPendingCount int64 + CloudStatusLastError string + CloudStatusLoading bool } // New creates a new TUI model connected to the given store. @@ -288,3 +307,31 @@ func loadCloudConfigCmd(dataDir string) tea.Cmd { } } } + +func loadCloudStatusCmd(s *store.Store) tea.Cmd { + return func() tea.Msg { + cc, err := loadCloudConfig(s.DataDir()) + if err != nil { + return cloudStatusLoadedMsg{err: err} + } + state, err := s.GetSyncState(store.DefaultSyncTargetKey) + if err != nil { + return cloudStatusLoadedMsg{err: err} + } + count, err := s.CountPendingSyncMutations(store.DefaultSyncTargetKey) + if err != nil { + return cloudStatusLoadedMsg{err: err} + } + lastErr := "" + if state.LastError != nil { + lastErr = *state.LastError + } + return cloudStatusLoadedMsg{ + serverURL: cc.ServerURL, + tokenSource: tokenSourceMessage(s.DataDir()), + lastSync: state.UpdatedAt, + pendingCount: count, + lastError: lastErr, + } + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 70c65bba..349f48fc 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -139,6 +139,33 @@ func TestScreenCloudConfigConstant(t *testing.T) { } } +func TestScreenCloudStatusConstant(t *testing.T) { + if ScreenCloudStatus != ScreenCloudConfig+1 { + t.Fatalf("ScreenCloudStatus = %d, want %d (ScreenCloudConfig+1)", ScreenCloudStatus, ScreenCloudConfig+1) + } + + seen := map[Screen]bool{} + for _, s := range []Screen{ + ScreenDashboard, + ScreenSearch, + ScreenSearchResults, + ScreenRecent, + ScreenObservationDetail, + ScreenTimeline, + ScreenSessions, + ScreenSessionDetail, + ScreenSetup, + ScreenCloudSettings, + ScreenCloudConfig, + ScreenCloudStatus, + } { + if seen[s] { + t.Fatalf("screen constant %d is duplicated", s) + } + seen[s] = true + } +} + func TestLoadCloudConfigCommand(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "cloud.json"), []byte(`{"server_url":"https://cloud.example.com","token":"file-token"}`), 0o644); err != nil { @@ -158,6 +185,83 @@ func TestLoadCloudConfigCommand(t *testing.T) { } } +func TestLoadCloudStatusCommand(t *testing.T) { + fx := newTestFixture(t) + if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`{"server_url":"https://cloud.example.com","token":"file-token"}`), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } + if err := fx.store.EnrollProject("engram"); err != nil { + t.Fatalf("enroll: %v", err) + } + // Adding an observation in an enrolled project creates a pending mutation. + if _, err := fx.store.AddObservation(store.AddObservationParams{ + SessionID: fx.sessionID, + Type: "decision", + Title: "Cloud status test", + Content: "pending mutation content", + Project: "engram", + Scope: "project", + }); err != nil { + t.Fatalf("add observation: %v", err) + } + + msg := loadCloudStatusCmd(fx.store)() + loaded, ok := msg.(cloudStatusLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if loaded.serverURL != "https://cloud.example.com" { + t.Fatalf("serverURL = %q", loaded.serverURL) + } + if loaded.tokenSource != TokenSourceFile { + t.Fatalf("tokenSource = %q, want %q", loaded.tokenSource, TokenSourceFile) + } + if loaded.pendingCount < 1 { + t.Fatalf("pendingCount = %d, want >= 1", loaded.pendingCount) + } + if loaded.lastSync == "" { + t.Fatal("lastSync should not be empty") + } +} + +func TestLoadCloudStatusCommandHandlesConfigError(t *testing.T) { + fx := newTestFixture(t) + if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`not json`), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } + + msg := loadCloudStatusCmd(fx.store)() + loaded, ok := msg.(cloudStatusLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err == nil { + t.Fatal("expected error for malformed cloud.json") + } +} + +func TestLoadCloudStatusCommandHandlesSyncStateError(t *testing.T) { + fx := newTestFixture(t) + if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`{"server_url":"https://cloud.example.com"}`), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } + if err := fx.store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + msg := loadCloudStatusCmd(fx.store)() + loaded, ok := msg.(cloudStatusLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err == nil { + t.Fatal("expected error when sync state cannot be read") + } +} + func TestInitReturnsCommand(t *testing.T) { m := New(newTestFixture(t).store, "") if cmd := m.Init(); cmd == nil { diff --git a/internal/tui/update.go b/internal/tui/update.go index 7c152c85..7134b08f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -134,7 +134,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.CloudConfigInput.Focus() return m, nil + case cloudStatusLoadedMsg: + m.CloudStatusLoading = false + if msg.err != nil { + m.CloudStatusLastError = msg.err.Error() + return m, nil + } + m.CloudStatusServerURL = msg.serverURL + m.CloudStatusTokenSource = msg.tokenSource + m.CloudStatusLastSync = msg.lastSync + m.CloudStatusPendingCount = msg.pendingCount + m.CloudStatusLastError = msg.lastError + if m.CloudStatusServerURL != "" && m.store != nil { + // Reuse the existing ping helper (validated by T-03) to check health. + return m, pingCloudServer(m.CloudStatusServerURL, effectiveCloudToken(m.store.DataDir())) + } + return m, nil + case cloudPingMsg: + // Status screen and config screen share the same ping result type, but + // update different model fields. + if m.Screen == ScreenCloudStatus { + m.CloudStatusHealth = msg.status + if msg.err != nil { + m.CloudStatusHealth = "unreachable" + m.CloudStatusLastError = msg.err.Error() + return m, nil + } + return m, nil + } m.CloudConfigSaving = false m.CloudConfigPingStatus = msg.status if msg.err != nil { @@ -218,6 +246,8 @@ func (m Model) handleKeyPress(key string) (tea.Model, tea.Cmd) { return m.handleCloudSettingsKeys(key) case ScreenCloudConfig: return m.handleCloudConfigKeys(key) + case ScreenCloudStatus: + return m.handleCloudStatusKeys(key) } return m, nil } @@ -679,6 +709,18 @@ func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) { m.CloudConfigInput.SetValue("") m.CloudConfigInput.Focus() return m, loadCloudConfigCmd(m.store.DataDir()) + case 1: // View status + m.PrevScreen = ScreenCloudSettings + m.Screen = ScreenCloudStatus + m.Cursor = 0 + m.CloudStatusLoading = true + m.CloudStatusServerURL = "" + m.CloudStatusTokenSource = "" + m.CloudStatusHealth = "" + m.CloudStatusLastSync = "" + m.CloudStatusPendingCount = 0 + m.CloudStatusLastError = "" + return m, loadCloudStatusCmd(m.store) case len(cloudSettingsMenuItems) - 1: // Back m.Screen = ScreenDashboard m.Cursor = 0 @@ -692,6 +734,18 @@ func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) { return m, nil } +// ─── Cloud Status ──────────────────────────────────────────────────────────── + +func (m Model) handleCloudStatusKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "esc", "q": + m.Screen = ScreenCloudSettings + m.Cursor = 1 // keep View status selected for smoother back navigation + return m, nil + } + return m, nil +} + // ─── Cloud Config ──────────────────────────────────────────────────────────── func (m Model) handleCloudConfigInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 0fa14841..c472517b 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -352,6 +352,90 @@ func TestCloudConfigNavigation(t *testing.T) { } } +func TestCloudStatusNavigation(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenCloudSettings + m.Cursor = 1 // View status + + updatedModel, cmd := m.handleCloudSettingsKeys("enter") + updated := updatedModel.(Model) + if updated.Screen != ScreenCloudStatus { + t.Fatalf("enter on View status should open ScreenCloudStatus, got %v", updated.Screen) + } + if cmd == nil { + t.Fatal("enter on View status should load cloud status") + } + + updatedModel, _ = updated.handleCloudStatusKeys("esc") + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("esc from cloud status should return to settings, got %v", updated.Screen) + } + + m = New(fx.store, "") + m.Screen = ScreenCloudStatus + updatedModel, _ = m.handleCloudStatusKeys("q") + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("q from cloud status should return to settings, got %v", updated.Screen) + } +} + +func TestCloudStatusLoadedMessage(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenCloudStatus + updatedModel, cmd := m.Update(cloudStatusLoadedMsg{ + serverURL: "https://cloud.example.com", + tokenSource: TokenSourceEnv, + lastSync: "2026-07-07 12:00:00", + pendingCount: 3, + lastError: "boom", + }) + updated := updatedModel.(Model) + if updated.CloudStatusServerURL != "https://cloud.example.com" { + t.Fatalf("serverURL = %q", updated.CloudStatusServerURL) + } + if updated.CloudStatusTokenSource != TokenSourceEnv { + t.Fatalf("tokenSource = %q", updated.CloudStatusTokenSource) + } + if updated.CloudStatusLastSync != "2026-07-07 12:00:00" { + t.Fatalf("lastSync = %q", updated.CloudStatusLastSync) + } + if updated.CloudStatusPendingCount != 3 { + t.Fatalf("pendingCount = %d", updated.CloudStatusPendingCount) + } + if updated.CloudStatusLastError != "boom" { + t.Fatalf("lastError = %q", updated.CloudStatusLastError) + } + if cmd == nil { + t.Fatal("loaded status with server URL should trigger ping") + } +} + +func TestCloudStatusPingUpdatesHealth(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "https://cloud.example.com" + + updatedModel, _ := m.Update(cloudPingMsg{status: "reachable"}) + updated := updatedModel.(Model) + if updated.CloudStatusHealth != "reachable" { + t.Fatalf("health = %q, want reachable", updated.CloudStatusHealth) + } + + m.Screen = ScreenCloudStatus + updatedModel, _ = m.Update(cloudPingMsg{status: "unreachable", err: errors.New("timeout")}) + updated = updatedModel.(Model) + if updated.CloudStatusHealth != "unreachable" { + t.Fatalf("health = %q, want unreachable", updated.CloudStatusHealth) + } + if updated.CloudStatusLastError != "timeout" { + t.Fatalf("lastError = %q, want timeout", updated.CloudStatusLastError) + } +} + func TestCloudConfigTabCyclesFocus(t *testing.T) { m := New(nil, "") m.Screen = ScreenCloudConfig @@ -805,6 +889,7 @@ func TestHandleKeyPressRouterAndClearsError(t *testing.T) { ScreenSetup, ScreenCloudSettings, ScreenCloudConfig, + ScreenCloudStatus, } { m.Screen = screen m.ErrorMsg = "old error" diff --git a/internal/tui/view.go b/internal/tui/view.go index 28be82b7..f52cfb87 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -84,6 +84,8 @@ func (m Model) View() string { content = m.viewCloudSettings() case ScreenCloudConfig: content = m.viewCloudConfig() + case ScreenCloudStatus: + content = m.viewCloudStatus() default: content = "Unknown screen" } @@ -235,6 +237,50 @@ func (m Model) viewCloudConfig() string { return b.String() } +func (m Model) viewCloudStatus() string { + var b strings.Builder + + b.WriteString(headerStyle.Render(" Cloud status")) + b.WriteString("\n\n") + + if m.CloudStatusServerURL == "" { + b.WriteString(noResultsStyle.Render("Cloud status: not configured")) + b.WriteString("\n") + b.WriteString(helpStyle.Render("\n esc/q back")) + return b.String() + } + + b.WriteString(renderCloudStatusRow("Server URL:", m.CloudStatusServerURL)) + b.WriteString(renderCloudStatusRow("Health:", renderCloudHealth(m.CloudStatusHealth))) + b.WriteString(renderCloudStatusRow("Token source:", m.CloudStatusTokenSource)) + b.WriteString(renderCloudStatusRow("Last sync:", m.CloudStatusLastSync)) + b.WriteString(renderCloudStatusRow("Pending:", fmt.Sprintf("%d", m.CloudStatusPendingCount))) + lastError := m.CloudStatusLastError + if lastError == "" { + lastError = "none" + } + b.WriteString(renderCloudStatusRow("Last error:", lastError)) + + b.WriteString(helpStyle.Render("\n esc/q back")) + + return b.String() +} + +func renderCloudStatusRow(label, value string) string { + return fmt.Sprintf("%s %s\n", detailLabelStyle.Render(label), detailValueStyle.Render(value)) +} + +func renderCloudHealth(health string) string { + switch health { + case "reachable": + return lipgloss.NewStyle().Foreground(colorGreen).Bold(true).Render("reachable") + case "unreachable": + return lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render("unreachable") + default: + return lipgloss.NewStyle().Foreground(colorYellow).Render(health) + } +} + // renderMenu renders a vertical list of selectable menu items with a cursor. func renderMenu(items []string, cursor int) string { var b strings.Builder diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index fd74bd9d..eb6dc8a8 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -108,6 +108,83 @@ func TestViewCloudConfigRendersTokenSource(t *testing.T) { } } +func TestViewCloudStatusRendersConfiguredState(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "https://cloud.example.com" + m.CloudStatusTokenSource = TokenSourceEnv + m.CloudStatusHealth = "reachable" + m.CloudStatusLastSync = "2026-07-07 12:00:00" + m.CloudStatusPendingCount = 2 + m.CloudStatusLastError = "last fail" + + out := m.View() + if !strings.Contains(out, "Cloud status") { + t.Fatal("view should render screen title") + } + if !strings.Contains(out, "https://cloud.example.com") { + t.Fatal("view should render server URL") + } + if !strings.Contains(out, "reachable") { + t.Fatal("view should render health") + } + if !strings.Contains(out, TokenSourceEnv) { + t.Fatal("view should render token source") + } + if !strings.Contains(out, "2026-07-07 12:00:00") { + t.Fatal("view should render last sync") + } + if !strings.Contains(out, "2") { + t.Fatal("view should render pending count") + } + if !strings.Contains(out, "last fail") { + t.Fatal("view should render last error") + } +} + +func TestViewCloudStatusNotConfigured(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "" + + out := m.View() + if !strings.Contains(out, "Cloud status: not configured") { + t.Fatalf("view should show not configured, got %q", out) + } +} + +func TestViewCloudStatusHealthAndTokenBranches(t *testing.T) { + tests := []struct { + name string + health string + tokenSource string + pending int64 + lastError string + want []string + }{ + {name: "reachable env", health: "reachable", tokenSource: TokenSourceEnv, pending: 0, lastError: "", want: []string{"reachable", TokenSourceEnv, "0"}}, + {name: "unreachable file error", health: "unreachable", tokenSource: TokenSourceFile, pending: 5, lastError: "boom", want: []string{"unreachable", TokenSourceFile, "5", "boom"}}, + {name: "unknown none", health: "unknown", tokenSource: TokenSourceNone, pending: 0, lastError: "", want: []string{"unknown", TokenSourceNone, "0"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "https://cloud.example.com" + m.CloudStatusHealth = tt.health + m.CloudStatusTokenSource = tt.tokenSource + m.CloudStatusPendingCount = tt.pending + m.CloudStatusLastError = tt.lastError + out := m.View() + for _, w := range tt.want { + if !strings.Contains(out, w) { + t.Fatalf("output missing %q: %s", w, out) + } + } + }) + } +} + func TestViewRouterAndErrorRendering(t *testing.T) { m := New(nil, "") m.Screen = Screen(999) @@ -393,6 +470,7 @@ func TestViewRouterCoversAllScreens(t *testing.T) { {screen: ScreenSetup, want: "Setup"}, {screen: ScreenCloudSettings, want: "Cloud sync settings"}, {screen: ScreenCloudConfig, want: "Configure cloud server"}, + {screen: ScreenCloudStatus, want: "Cloud status"}, } for _, tt := range tests { From fca5aa54a74c336f350a74bfb2201ca0e6c0e4aa Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Wed, 8 Jul 2026 12:20:06 -0600 Subject: [PATCH 07/10] feat(tui): add project enrollment toggles --- internal/tui/model.go | 34 ++++++ internal/tui/model_test.go | 69 ++++++++++++ internal/tui/update.go | 57 ++++++++++ internal/tui/update_test.go | 215 ++++++++++++++++++++++++++++++++++++ internal/tui/view.go | 52 +++++++++ internal/tui/view_test.go | 67 +++++++++++ 6 files changed, 494 insertions(+) diff --git a/internal/tui/model.go b/internal/tui/model.go index 90a67a2c..3b139783 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -37,6 +37,7 @@ const ( ScreenCloudSettings ScreenCloudConfig ScreenCloudStatus + ScreenCloudEnrollment ) // Cloud Config form focus positions. @@ -114,6 +115,16 @@ type cloudStatusLoadedMsg struct { err error } +type cloudEnrollmentItem struct { + project string + enrolled bool +} + +type cloudEnrollmentLoadedMsg struct { + items []cloudEnrollmentItem + err error +} + // ─── Model ─────────────────────────────────────────────────────────────────── type Model struct { @@ -190,6 +201,11 @@ type Model struct { CloudStatusPendingCount int64 CloudStatusLastError string CloudStatusLoading bool + + // Cloud enrollment + CloudEnrollmentItems []cloudEnrollmentItem + CloudEnrollmentError string + CloudEnrollmentLoading bool } // New creates a new TUI model connected to the given store. @@ -335,3 +351,21 @@ func loadCloudStatusCmd(s *store.Store) tea.Cmd { } } } + +func loadCloudEnrollmentCmd(s *store.Store) tea.Cmd { + return func() tea.Msg { + names, err := s.ListProjectNames() + if err != nil { + return cloudEnrollmentLoadedMsg{err: err} + } + items := make([]cloudEnrollmentItem, 0, len(names)) + for _, name := range names { + enrolled, err := s.IsProjectEnrolled(name) + if err != nil { + return cloudEnrollmentLoadedMsg{err: err} + } + items = append(items, cloudEnrollmentItem{project: name, enrolled: enrolled}) + } + return cloudEnrollmentLoadedMsg{items: items} + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 349f48fc..edcfbb16 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -166,6 +166,34 @@ func TestScreenCloudStatusConstant(t *testing.T) { } } +func TestScreenCloudEnrollmentConstant(t *testing.T) { + if ScreenCloudEnrollment != ScreenCloudStatus+1 { + t.Fatalf("ScreenCloudEnrollment = %d, want %d (ScreenCloudStatus+1)", ScreenCloudEnrollment, ScreenCloudStatus+1) + } + + seen := map[Screen]bool{} + for _, s := range []Screen{ + ScreenDashboard, + ScreenSearch, + ScreenSearchResults, + ScreenRecent, + ScreenObservationDetail, + ScreenTimeline, + ScreenSessions, + ScreenSessionDetail, + ScreenSetup, + ScreenCloudSettings, + ScreenCloudConfig, + ScreenCloudStatus, + ScreenCloudEnrollment, + } { + if seen[s] { + t.Fatalf("screen constant %d is duplicated", s) + } + seen[s] = true + } +} + func TestLoadCloudConfigCommand(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "cloud.json"), []byte(`{"server_url":"https://cloud.example.com","token":"file-token"}`), 0o644); err != nil { @@ -262,6 +290,47 @@ func TestLoadCloudStatusCommandHandlesSyncStateError(t *testing.T) { } } +func TestLoadCloudEnrollmentCommand(t *testing.T) { + fx := newTestFixture(t) + if err := fx.store.EnrollProject("engram"); err != nil { + t.Fatalf("enroll project: %v", err) + } + + msg := loadCloudEnrollmentCmd(fx.store)() + loaded, ok := msg.(cloudEnrollmentLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if len(loaded.items) != 1 { + t.Fatalf("items = %d, want 1", len(loaded.items)) + } + if loaded.items[0].project != "engram" { + t.Fatalf("project = %q, want engram", loaded.items[0].project) + } + if !loaded.items[0].enrolled { + t.Fatal("engram should be enrolled") + } +} + +func TestLoadCloudEnrollmentCommandHandlesError(t *testing.T) { + fx := newTestFixture(t) + if err := fx.store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + msg := loadCloudEnrollmentCmd(fx.store)() + loaded, ok := msg.(cloudEnrollmentLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err == nil { + t.Fatal("expected error when store is closed") + } +} + func TestInitReturnsCommand(t *testing.T) { m := New(newTestFixture(t).store, "") if cmd := m.Init(); cmd == nil { diff --git a/internal/tui/update.go b/internal/tui/update.go index 7134b08f..25c420b6 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -151,6 +151,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case cloudEnrollmentLoadedMsg: + m.CloudEnrollmentLoading = false + if msg.err != nil { + m.CloudEnrollmentError = msg.err.Error() + return m, nil + } + m.CloudEnrollmentItems = msg.items + m.CloudEnrollmentError = "" + return m, nil + case cloudPingMsg: // Status screen and config screen share the same ping result type, but // update different model fields. @@ -248,6 +258,8 @@ func (m Model) handleKeyPress(key string) (tea.Model, tea.Cmd) { return m.handleCloudConfigKeys(key) case ScreenCloudStatus: return m.handleCloudStatusKeys(key) + case ScreenCloudEnrollment: + return m.handleCloudEnrollmentKeys(key) } return m, nil } @@ -721,6 +733,14 @@ func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) { m.CloudStatusPendingCount = 0 m.CloudStatusLastError = "" return m, loadCloudStatusCmd(m.store) + case 2: // Enroll projects + m.PrevScreen = ScreenCloudSettings + m.Screen = ScreenCloudEnrollment + m.Cursor = 0 + m.CloudEnrollmentLoading = true + m.CloudEnrollmentItems = nil + m.CloudEnrollmentError = "" + return m, loadCloudEnrollmentCmd(m.store) case len(cloudSettingsMenuItems) - 1: // Back m.Screen = ScreenDashboard m.Cursor = 0 @@ -746,6 +766,43 @@ func (m Model) handleCloudStatusKeys(key string) (tea.Model, tea.Cmd) { return m, nil } +// ─── Cloud Enrollment ──────────────────────────────────────────────────────── + +func (m Model) handleCloudEnrollmentKeys(key string) (tea.Model, tea.Cmd) { + count := len(m.CloudEnrollmentItems) + + switch key { + case "up", "k": + if m.Cursor > 0 { + m.Cursor-- + } + case "down", "j": + if m.Cursor < count-1 { + m.Cursor++ + } + case " ": + if count > 0 && m.Cursor < count && m.store != nil { + item := m.CloudEnrollmentItems[m.Cursor] + m.CloudEnrollmentError = "" + if item.enrolled { + if err := m.store.UnenrollProject(item.project); err != nil { + m.CloudEnrollmentError = err.Error() + } + } else { + if err := m.store.EnrollProject(item.project); err != nil { + m.CloudEnrollmentError = err.Error() + } + } + return m, loadCloudEnrollmentCmd(m.store) + } + case "esc", "q": + m.Screen = ScreenCloudSettings + m.Cursor = 2 // keep Enroll projects selected for smoother back navigation + return m, nil + } + return m, nil +} + // ─── Cloud Config ──────────────────────────────────────────────────────────── func (m Model) handleCloudConfigInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index c472517b..21ff0f27 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -382,6 +382,220 @@ func TestCloudStatusNavigation(t *testing.T) { } } +func TestCloudEnrollmentNavigation(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenCloudSettings + m.Cursor = 2 // Enroll projects + + updatedModel, cmd := m.handleCloudSettingsKeys("enter") + updated := updatedModel.(Model) + if updated.Screen != ScreenCloudEnrollment { + t.Fatalf("enter on Enroll projects should open ScreenCloudEnrollment, got %v", updated.Screen) + } + if !updated.CloudEnrollmentLoading { + t.Fatal("enrollment screen should set loading state") + } + if cmd == nil { + t.Fatal("enter on Enroll projects should load enrollment list") + } + + updatedModel, _ = updated.handleCloudEnrollmentKeys("esc") + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("esc from enrollment should return to settings, got %v", updated.Screen) + } + + m = New(fx.store, "") + m.Screen = ScreenCloudEnrollment + updatedModel, _ = m.handleCloudEnrollmentKeys("q") + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("q from enrollment should return to settings, got %v", updated.Screen) + } +} + +func TestCloudEnrollmentLoadedMessage(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentLoading = true + + updatedModel, _ := m.Update(cloudEnrollmentLoadedMsg{ + items: []cloudEnrollmentItem{ + {project: "sias-app", enrolled: true}, + {project: "dotfiles", enrolled: false}, + }, + }) + updated := updatedModel.(Model) + if updated.CloudEnrollmentLoading { + t.Fatal("loaded message should clear loading state") + } + if len(updated.CloudEnrollmentItems) != 2 { + t.Fatalf("items = %d, want 2", len(updated.CloudEnrollmentItems)) + } + if updated.CloudEnrollmentItems[0].project != "sias-app" || !updated.CloudEnrollmentItems[0].enrolled { + t.Fatal("first item should be sias-app enrolled") + } + if updated.CloudEnrollmentItems[1].project != "dotfiles" || updated.CloudEnrollmentItems[1].enrolled { + t.Fatal("second item should be dotfiles not enrolled") + } +} + +func TestCloudEnrollmentToggleEnrollsProject(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = []cloudEnrollmentItem{{project: "engram", enrolled: false}} + m.Cursor = 0 + + updatedModel, cmd := m.handleCloudEnrollmentKeys(" ") + updated := updatedModel.(Model) + if updated.CloudEnrollmentItems[0].enrolled { + t.Fatal("toggle should not mutate list until reload completes") + } + if cmd == nil { + t.Fatal("space on unenrolled project should return reload command") + } + + msg := cmd() + loaded, ok := msg.(cloudEnrollmentLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected reload error: %v", loaded.err) + } + if len(loaded.items) != 1 || !loaded.items[0].enrolled { + t.Fatal("engram should be enrolled after toggle") + } +} + +func TestCloudEnrollmentToggleUnenrollsProject(t *testing.T) { + fx := newTestFixture(t) + if err := fx.store.EnrollProject("engram"); err != nil { + t.Fatalf("enroll project: %v", err) + } + + m := New(fx.store, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = []cloudEnrollmentItem{{project: "engram", enrolled: true}} + m.Cursor = 0 + + updatedModel, cmd := m.handleCloudEnrollmentKeys(" ") + updated := updatedModel.(Model) + if !updated.CloudEnrollmentItems[0].enrolled { + t.Fatal("toggle should not mutate list until reload completes") + } + if cmd == nil { + t.Fatal("space on enrolled project should return reload command") + } + + msg := cmd() + loaded, ok := msg.(cloudEnrollmentLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected reload error: %v", loaded.err) + } + if len(loaded.items) != 1 || loaded.items[0].enrolled { + t.Fatal("engram should be unenrolled after toggle") + } +} + +func TestCloudEnrollmentToggleErrorSurfaced(t *testing.T) { + fx := newTestFixture(t) + if err := fx.store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + m := New(fx.store, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = []cloudEnrollmentItem{{project: "engram", enrolled: false}} + m.Cursor = 0 + + updatedModel, cmd := m.handleCloudEnrollmentKeys(" ") + updated := updatedModel.(Model) + if cmd == nil { + t.Fatal("space should return command even on error") + } + + msg := cmd() + loaded, ok := msg.(cloudEnrollmentLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err == nil { + t.Fatal("expected error from closed store") + } + if updated.CloudEnrollmentError == "" { + t.Fatal("toggle error should be surfaced in model") + } +} + +func TestCloudEnrollmentNavigationBoundaries(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = []cloudEnrollmentItem{ + {project: "a", enrolled: false}, + {project: "b", enrolled: true}, + {project: "c", enrolled: false}, + } + m.Cursor = 0 + + updatedModel, _ := m.handleCloudEnrollmentKeys("up") + if updatedModel.(Model).Cursor != 0 { + t.Fatal("up at top should stay at zero") + } + + m.Cursor = 2 + updatedModel, _ = m.handleCloudEnrollmentKeys("down") + if updatedModel.(Model).Cursor != 2 { + t.Fatal("down at bottom should stay at last item") + } + + updatedModel, _ = m.handleCloudEnrollmentKeys("k") + if updatedModel.(Model).Cursor != 1 { + t.Fatal("k should decrement cursor") + } + + updatedModel, _ = updatedModel.(Model).handleCloudEnrollmentKeys("j") + if updatedModel.(Model).Cursor != 2 { + t.Fatal("j should increment cursor") + } +} + +func TestCloudEnrollmentSpaceOnEmptyListDoesNothing(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = nil + + updatedModel, cmd := m.handleCloudEnrollmentKeys(" ") + updated := updatedModel.(Model) + if updated.Screen != ScreenCloudEnrollment { + t.Fatal("space on empty list should stay on screen") + } + if cmd != nil { + t.Fatal("space on empty list should not return command") + } +} + +func TestCloudEnrollmentSpaceWithoutStoreDoesNothing(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = []cloudEnrollmentItem{{project: "engram", enrolled: false}} + m.Cursor = 0 + + updatedModel, cmd := m.handleCloudEnrollmentKeys(" ") + updated := updatedModel.(Model) + if updated.Screen != ScreenCloudEnrollment { + t.Fatal("space without store should stay on screen") + } + if cmd != nil { + t.Fatal("space without store should not return command") + } +} + func TestCloudStatusLoadedMessage(t *testing.T) { fx := newTestFixture(t) m := New(fx.store, "") @@ -890,6 +1104,7 @@ func TestHandleKeyPressRouterAndClearsError(t *testing.T) { ScreenCloudSettings, ScreenCloudConfig, ScreenCloudStatus, + ScreenCloudEnrollment, } { m.Screen = screen m.ErrorMsg = "old error" diff --git a/internal/tui/view.go b/internal/tui/view.go index f52cfb87..92632ceb 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -86,6 +86,8 @@ func (m Model) View() string { content = m.viewCloudConfig() case ScreenCloudStatus: content = m.viewCloudStatus() + case ScreenCloudEnrollment: + content = m.viewCloudEnrollment() default: content = "Unknown screen" } @@ -266,6 +268,56 @@ func (m Model) viewCloudStatus() string { return b.String() } +func (m Model) viewCloudEnrollment() string { + var b strings.Builder + + b.WriteString(headerStyle.Render(" Enroll projects")) + b.WriteString("\n\n") + + if len(m.CloudEnrollmentItems) == 0 { + b.WriteString(noResultsStyle.Render("No projects found.")) + b.WriteString("\n\n") + if m.CloudEnrollmentError != "" { + b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError)) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render(" esc back")) + return b.String() + } + + for i, item := range m.CloudEnrollmentItems { + cursor := " " + style := listItemStyle + if i == m.Cursor { + cursor = "▸ " + style = listSelectedStyle + } + + badge := "[not enrolled]" + badgeStyle := timestampStyle + if item.enrolled { + badge = "[enrolled]" + badgeStyle = lipgloss.NewStyle().Foreground(colorGreen).Bold(true) + } + + line := fmt.Sprintf("%s%s %s", + cursor, + style.Render(truncateStr(item.project, 50)), + badgeStyle.Render(badge)) + b.WriteString(line) + b.WriteString("\n") + } + + if m.CloudEnrollmentError != "" { + b.WriteString("\n") + b.WriteString(errorStyle.Render("Error: " + m.CloudEnrollmentError)) + } + + b.WriteString(helpStyle.Render("\n j/k navigate • space toggle • esc/q back")) + + return b.String() +} + func renderCloudStatusRow(label, value string) string { return fmt.Sprintf("%s %s\n", detailLabelStyle.Render(label), detailValueStyle.Render(value)) } diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index eb6dc8a8..31d33417 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -199,6 +199,72 @@ func TestViewRouterAndErrorRendering(t *testing.T) { } } +func TestViewCloudEnrollmentRendersProjects(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = []cloudEnrollmentItem{ + {project: "sias-app", enrolled: true}, + {project: "dotfiles", enrolled: false}, + } + m.Cursor = 1 + + out := m.viewCloudEnrollment() + if !strings.Contains(out, "Enroll projects") { + t.Fatal("view should render screen title") + } + if !strings.Contains(out, "sias-app") { + t.Fatal("view should render first project") + } + if !strings.Contains(out, "dotfiles") { + t.Fatal("view should render second project") + } + if !strings.Contains(out, "[enrolled]") { + t.Fatal("view should render enrolled badge") + } + if !strings.Contains(out, "[not enrolled]") { + t.Fatal("view should render not enrolled badge") + } +} + +func TestViewCloudEnrollmentEmptyState(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = nil + + out := m.viewCloudEnrollment() + if !strings.Contains(out, "No projects") { + t.Fatalf("view should show empty state, got %q", out) + } + if !strings.Contains(out, "esc back") { + t.Fatal("empty state should include esc back hint") + } +} + +func TestViewCloudEnrollmentLongName(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentItems = []cloudEnrollmentItem{ + {project: strings.Repeat("a", 200), enrolled: false}, + } + m.Cursor = 0 + + out := m.viewCloudEnrollment() + if !strings.Contains(out, "[not enrolled]") { + t.Fatal("view should render badge for long project name") + } +} + +func TestViewCloudEnrollmentErrorSurfaced(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudEnrollment + m.CloudEnrollmentError = "database locked" + + out := m.viewCloudEnrollment() + if !strings.Contains(out, "database locked") { + t.Fatal("view should render enrollment error") + } +} + func TestViewSearchResultsAndScrollIndicator(t *testing.T) { m := New(nil, "") m.Screen = ScreenSearchResults @@ -471,6 +537,7 @@ func TestViewRouterCoversAllScreens(t *testing.T) { {screen: ScreenCloudSettings, want: "Cloud sync settings"}, {screen: ScreenCloudConfig, want: "Configure cloud server"}, {screen: ScreenCloudStatus, want: "Cloud status"}, + {screen: ScreenCloudEnrollment, want: "Enroll projects"}, } for _, tt := range tests { From 47f2b48953c567cedf187e2122535ab7a0e67933 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Wed, 8 Jul 2026 12:45:36 -0600 Subject: [PATCH 08/10] test(tui): close per-function coverage gaps on cloud config --- internal/tui/cloud_test.go | 72 +++++++++++++++++ internal/tui/update_test.go | 153 ++++++++++++++++++++++++++++++++++++ internal/tui/view_test.go | 50 ++++++++++++ 3 files changed, 275 insertions(+) diff --git a/internal/tui/cloud_test.go b/internal/tui/cloud_test.go index c125ad25..47cfe44c 100644 --- a/internal/tui/cloud_test.go +++ b/internal/tui/cloud_test.go @@ -222,4 +222,76 @@ func TestValidateCloudServerURL(t *testing.T) { } } +func TestEffectiveCloudTokenEnvWins(t *testing.T) { + dir := t.TempDir() + writeCloudJSON(t, dir, `{"token":"file-token"}`) + + t.Setenv("ENGRAM_CLOUD_TOKEN", "env-token") + if got := effectiveCloudToken(dir); got != "env-token" { + t.Fatalf("token = %q, want %q", got, "env-token") + } +} + +func TestEffectiveCloudTokenFileFallback(t *testing.T) { + dir := t.TempDir() + writeCloudJSON(t, dir, `{"token":"file-token"}`) + + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + if got := effectiveCloudToken(dir); got != "file-token" { + t.Fatalf("token = %q, want %q", got, "file-token") + } +} + +func TestEffectiveCloudTokenNone(t *testing.T) { + dir := t.TempDir() + + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + if got := effectiveCloudToken(dir); got != "" { + t.Fatalf("token = %q, want empty", got) + } +} + +func TestEffectiveCloudTokenTrimsWhitespace(t *testing.T) { + dir := t.TempDir() + writeCloudJSON(t, dir, `{"token":" file-token "}`) + + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + if got := effectiveCloudToken(dir); got != "file-token" { + t.Fatalf("token = %q, want %q", got, "file-token") + } + + t.Setenv("ENGRAM_CLOUD_TOKEN", " env-token ") + if got := effectiveCloudToken(dir); got != "env-token" { + t.Fatalf("env token = %q, want %q", got, "env-token") + } +} + +func TestSaveCloudConfigMissingFileCreatesNew(t *testing.T) { + dir := t.TempDir() + + if err := saveCloudConfig(dir, "https://new.example.com"); err != nil { + t.Fatalf("saveCloudConfig: %v", err) + } + + b, err := os.ReadFile(filepath.Join(dir, "cloud.json")) + if err != nil { + t.Fatalf("read cloud.json: %v", err) + } + if !bytes.Contains(b, []byte(`"server_url": "https://new.example.com"`)) { + t.Fatalf("server_url not written in %s", string(b)) + } + if bytes.Contains(b, []byte(`"token"`)) { + t.Fatalf("token must not appear in fresh save, got %s", string(b)) + } +} + +func TestSaveCloudConfigMalformedJSONReturnsError(t *testing.T) { + dir := t.TempDir() + writeCloudJSON(t, dir, `{not json`) + + if err := saveCloudConfig(dir, "https://new.example.com"); err == nil { + t.Fatal("expected error for malformed cloud.json") + } +} + diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 21ff0f27..e539e806 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -693,6 +693,159 @@ func TestCloudConfigTabCyclesFocus(t *testing.T) { } } +func TestCloudConfigInputKeys(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudConfig + m.CloudConfigFocus = cloudConfigFocusInput + m.CloudConfigInput.Focus() + m.CloudConfigInput.SetValue("https://cloud.example.com") + + updatedModel, _ := m.handleCloudConfigInputKeys(tea.KeyMsg{Type: tea.KeyTab}) + updated := updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusTest { + t.Fatalf("tab should move focus to test, got %d", updated.CloudConfigFocus) + } + if updated.CloudConfigInput.Focused() { + t.Fatal("input should blur on tab") + } + + updated.CloudConfigFocus = cloudConfigFocusInput + updated.CloudConfigInput.Focus() + updatedModel, _ = updated.handleCloudConfigInputKeys(tea.KeyMsg{Type: tea.KeyShiftTab}) + updated = updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusCancel { + t.Fatalf("shift+tab should move focus to cancel, got %d", updated.CloudConfigFocus) + } + + updated.CloudConfigFocus = cloudConfigFocusInput + updated.CloudConfigInput.Focus() + updatedModel, _ = updated.handleCloudConfigInputKeys(tea.KeyMsg{Type: tea.KeyEnter}) + updated = updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusSave { + t.Fatalf("enter should move focus to save, got %d", updated.CloudConfigFocus) + } + + updated.CloudConfigFocus = cloudConfigFocusInput + updated.CloudConfigInput.Focus() + updatedModel, _ = updated.handleCloudConfigInputKeys(tea.KeyMsg{Type: tea.KeyEscape}) + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("esc should return to cloud settings, got %v", updated.Screen) + } + if updated.CloudConfigFocus != cloudConfigFocusInput { + t.Fatalf("esc should reset focus to input, got %d", updated.CloudConfigFocus) + } + + updated = New(nil, "") + updated.Screen = ScreenCloudConfig + updated.CloudConfigFocus = cloudConfigFocusInput + updated.CloudConfigInput.Focus() + updatedModel, cmd := updated.handleCloudConfigInputKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + updated = updatedModel.(Model) + if updated.CloudConfigInput.Value() != "a" { + t.Fatalf("typed key should update input, got %q", updated.CloudConfigInput.Value()) + } + if cmd == nil { + t.Fatal("typing key should return input update command") + } +} + +func TestCloudConfigKeysVimAliasesAndUnknown(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudConfig + m.CloudConfigFocus = cloudConfigFocusTest + + updatedModel, _ := m.handleCloudConfigKeys("k") + updated := updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusInput { + t.Fatalf("k should move to input, got %d", updated.CloudConfigFocus) + } + if !updated.CloudConfigInput.Focused() { + t.Fatal("k moving to input should focus it") + } + + updatedModel, _ = updated.handleCloudConfigKeys("j") + updated = updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusTest { + t.Fatalf("j should move to test, got %d", updated.CloudConfigFocus) + } + if updated.CloudConfigInput.Focused() { + t.Fatal("j moving away from input should blur it") + } + + updatedModel, cmd := updated.handleCloudConfigKeys("x") + updated = updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusTest { + t.Fatalf("unknown key should not change focus, got %d", updated.CloudConfigFocus) + } + if cmd != nil { + t.Fatal("unknown key should not return command") + } +} + +func TestActivateCloudConfigFocusTargets(t *testing.T) { + fx := newTestFixture(t) + + // Input focus moves to Save without a command. + m := New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigFocus = cloudConfigFocusInput + m.CloudConfigInput.Focus() + updatedModel, cmd := m.activateCloudConfigFocus() + updated := updatedModel.(Model) + if updated.CloudConfigFocus != cloudConfigFocusSave { + t.Fatalf("input focus should move to save, got %d", updated.CloudConfigFocus) + } + if cmd != nil { + t.Fatal("input focus should not return command") + } + + // Test focus starts a test ping. + m = New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigFocus = cloudConfigFocusTest + m.CloudConfigInput.SetValue("https://cloud.example.com") + updatedModel, cmd = m.activateCloudConfigFocus() + updated = updatedModel.(Model) + if !updated.CloudConfigTest { + t.Fatal("test focus should set CloudConfigTest") + } + if cmd == nil { + t.Fatal("test focus should return ping command") + } + + // Save focus starts a save ping. + m = New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigFocus = cloudConfigFocusSave + m.CloudConfigInput.SetValue("https://cloud.example.com") + updatedModel, cmd = m.activateCloudConfigFocus() + updated = updatedModel.(Model) + if !updated.CloudConfigSaving { + t.Fatal("save focus should set CloudConfigSaving") + } + if cmd == nil { + t.Fatal("save focus should return ping command") + } + + // Cancel focus returns to cloud settings. + m = New(fx.store, "") + m.Screen = ScreenCloudConfig + m.CloudConfigFocus = cloudConfigFocusCancel + m.CloudConfigInput.Focus() + updatedModel, cmd = m.activateCloudConfigFocus() + updated = updatedModel.(Model) + if updated.Screen != ScreenCloudSettings { + t.Fatalf("cancel focus should return to settings, got %v", updated.Screen) + } + if updated.CloudConfigFocus != cloudConfigFocusInput { + t.Fatalf("cancel focus should reset focus to input, got %d", updated.CloudConfigFocus) + } + if cmd != nil { + t.Fatal("cancel focus should not return command") + } +} + func TestCloudConfigSaveValidURLPersists(t *testing.T) { fx := newTestFixture(t) server := httptest.NewServer(httpHandlerWithStatus(200)) diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 31d33417..bab0a5df 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -108,6 +108,56 @@ func TestViewCloudConfigRendersTokenSource(t *testing.T) { } } +func TestViewCloudConfigBranches(t *testing.T) { + // Empty input value and non-input focus renders value style. + m := New(nil, "") + m.Screen = ScreenCloudConfig + m.CloudConfigFocus = cloudConfigFocusTest + m.CloudConfigInput.SetValue("") + out := m.viewCloudConfig() + if !strings.Contains(out, "Server URL") { + t.Fatal("view should render URL label for empty input") + } + + // Spinner branch during save. + m.CloudConfigFocus = cloudConfigFocusSave + m.CloudConfigInput.SetValue("https://cloud.example.com") + m.CloudConfigSaving = true + out = m.viewCloudConfig() + if !strings.Contains(out, "Pinging server") { + t.Fatalf("view should render spinner branch, got %q", out) + } + + // Ping status branch. + m.CloudConfigSaving = false + m.CloudConfigPingStatus = "reachable" + out = m.viewCloudConfig() + if !strings.Contains(out, "Status: reachable") { + t.Fatalf("view should render ping status, got %q", out) + } + + // Token source hint hidden for env token. + m.CloudConfigTokenSource = TokenSourceEnv + out = m.viewCloudConfig() + if strings.Contains(out, "Set ENGRAM_CLOUD_TOKEN") { + t.Fatal("env token source should not show fallback hint") + } + + // Token source hint shown for file token. + m.CloudConfigTokenSource = TokenSourceFile + out = m.viewCloudConfig() + if !strings.Contains(out, "Set ENGRAM_CLOUD_TOKEN") { + t.Fatal("file token source should show fallback hint") + } + + // Token source hint shown when no token. + m.CloudConfigTokenSource = TokenSourceNone + out = m.viewCloudConfig() + if !strings.Contains(out, "Set ENGRAM_CLOUD_TOKEN") { + t.Fatal("no token source should show fallback hint") + } +} + func TestViewCloudStatusRendersConfiguredState(t *testing.T) { m := New(nil, "") m.Screen = ScreenCloudStatus From 748f09f8f64b04a87e0b422fe8b8443088f6cbb7 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Wed, 8 Jul 2026 12:45:41 -0600 Subject: [PATCH 09/10] test(tui): harden cloud config tests against ENGRAM_CLOUD_TOKEN env --- internal/tui/model_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index edcfbb16..01bb38a3 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -214,6 +214,9 @@ func TestLoadCloudConfigCommand(t *testing.T) { } func TestLoadCloudStatusCommand(t *testing.T) { + // This test asserts TokenSourceFile, so ensure the env var cannot override it. + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + fx := newTestFixture(t) if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`{"server_url":"https://cloud.example.com","token":"file-token"}`), 0o644); err != nil { t.Fatalf("write cloud.json: %v", err) @@ -255,6 +258,30 @@ func TestLoadCloudStatusCommand(t *testing.T) { } } +func TestLoadCloudStatusCommandIgnoresShellEnvVar(t *testing.T) { + // Simulate the shell setting ENGRAM_CLOUD_TOKEN; t.Setenv must scope the unset to this test. + t.Setenv("ENGRAM_CLOUD_TOKEN", "shell-token") + // Intentionally override with empty string so the file token is used. + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + + fx := newTestFixture(t) + if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`{"server_url":"https://cloud.example.com","token":"file-token"}`), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } + + msg := loadCloudStatusCmd(fx.store)() + loaded, ok := msg.(cloudStatusLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if loaded.tokenSource != TokenSourceFile { + t.Fatalf("tokenSource = %q, want %q", loaded.tokenSource, TokenSourceFile) + } +} + func TestLoadCloudStatusCommandHandlesConfigError(t *testing.T) { fx := newTestFixture(t) if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`not json`), 0o644); err != nil { From 8ff8ed43cf3f46153ec1a7bcdb8c65633e9ff24c Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Wed, 8 Jul 2026 14:02:11 -0600 Subject: [PATCH 10/10] feat(tui): add CLI parity fields to cloud status view --- internal/tui/cloud.go | 75 +++++++++++++++++++ internal/tui/cloud_test.go | 125 +++++++++++++++++++++++++++++++ internal/tui/model.go | 128 ++++++++++++++++++++++++++----- internal/tui/model_test.go | 110 +++++++++++++++++++++++++++ internal/tui/update.go | 27 ++++++- internal/tui/view.go | 61 +++++++++++++-- internal/tui/view_test.go | 149 +++++++++++++++++++++++++++++++++++++ 7 files changed, 646 insertions(+), 29 deletions(-) diff --git a/internal/tui/cloud.go b/internal/tui/cloud.go index b50c6dc2..68dee7de 100644 --- a/internal/tui/cloud.go +++ b/internal/tui/cloud.go @@ -1,12 +1,17 @@ package tui import ( + "context" "encoding/json" + "errors" "fmt" + "io" + "net" "net/http" "net/url" "os" "path/filepath" + "strconv" "strings" "time" @@ -153,4 +158,74 @@ func validateCloudServerURL(raw string) (string, error) { return parsed.String(), nil } +// isInsecureNoAuth mirrors cmd/engram/main.go:envBool for the +// ENGRAM_CLOUD_INSECURE_NO_AUTH env var. Truthy values are "1", "true", +// "yes" and "on" (case-insensitive, whitespace-trimmed). +func isInsecureNoAuth() bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv("ENGRAM_CLOUD_INSECURE_NO_AUTH"))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + +// daemonProbeStatus describes the outcome of probing the local engram daemon. +type daemonProbeStatus string + +const ( + daemonProbeRunning daemonProbeStatus = "running" + daemonProbeNotRunning daemonProbeStatus = "not_running" + daemonProbeUnreachable daemonProbeStatus = "unreachable" +) + +// daemonProbeResult captures the outcome of a single probe. +type daemonProbeResult struct { + Status daemonProbeStatus + Port int + Err error +} + +const defaultDaemonProbePort = 7437 + +// daemonProbeTimeout is a var (not const) so tests can shorten it when +// exercising slow paths. +var daemonProbeTimeout = time.Second + +// daemonProbeTransport can be overridden in tests to avoid real network calls. +var daemonProbeTransport http.RoundTripper = http.DefaultTransport + +// probeLocalDaemon mirrors cmd/engram/cloud_daemon_probe.go:defaultCloudDaemonProbe. +// A dial error to 127.0.0.1 is interpreted as "not running"; any other error +// (timeout, non-2xx response, malformed reply) maps to "unreachable". +func probeLocalDaemon(ctx context.Context, port int) daemonProbeResult { + url := fmt.Sprintf("http://127.0.0.1:%d/health", port) + client := &http.Client{Timeout: daemonProbeTimeout, Transport: daemonProbeTransport} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return daemonProbeResult{Status: daemonProbeUnreachable, Port: port, Err: err} + } + resp, err := client.Do(req) + if err != nil { + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Op == "dial" { + return daemonProbeResult{Status: daemonProbeNotRunning, Port: port, Err: err} + } + return daemonProbeResult{Status: daemonProbeUnreachable, Port: port, Err: err} + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return daemonProbeResult{Status: daemonProbeRunning, Port: port} + } + return daemonProbeResult{Status: daemonProbeUnreachable, Port: port} +} + +// resolveDaemonProbePort mirrors cmd/engram/cloud_daemon_probe.go:resolveDaemonProbePort. +// It reads ENGRAM_PORT and falls back to 7437. +func resolveDaemonProbePort() int { + if p := strings.TrimSpace(os.Getenv("ENGRAM_PORT")); p != "" { + if n, err := strconv.Atoi(p); err == nil && n > 0 && n < 65536 { + return n + } + } + return defaultDaemonProbePort +} + diff --git a/internal/tui/cloud_test.go b/internal/tui/cloud_test.go index 47cfe44c..05264d40 100644 --- a/internal/tui/cloud_test.go +++ b/internal/tui/cloud_test.go @@ -2,9 +2,12 @@ package tui import ( "bytes" + "context" "errors" "io" + "net" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -294,4 +297,126 @@ func TestSaveCloudConfigMalformedJSONReturnsError(t *testing.T) { } } +func TestIsInsecureNoAuth(t *testing.T) { + tests := []struct { + name string + env string + want bool + }{ + {"empty", "", false}, + {"1", "1", true}, + {"true", "true", true}, + {"True", "True", true}, + {"TRUE", "TRUE", true}, + {"yes", "yes", true}, + {"on", "on", true}, + {"0", "0", false}, + {"false", "false", false}, + {"no", "no", false}, + {"whitespace 1", " 1 ", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ENGRAM_CLOUD_INSECURE_NO_AUTH", tt.env) + if got := isInsecureNoAuth(); got != tt.want { + t.Fatalf("isInsecureNoAuth() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestResolveDaemonProbePort(t *testing.T) { + t.Run("default", func(t *testing.T) { + t.Setenv("ENGRAM_PORT", "") + if got := resolveDaemonProbePort(); got != 7437 { + t.Fatalf("port = %d, want 7437", got) + } + }) + t.Run("custom", func(t *testing.T) { + t.Setenv("ENGRAM_PORT", "9999") + if got := resolveDaemonProbePort(); got != 9999 { + t.Fatalf("port = %d, want 9999", got) + } + }) + t.Run("invalid falls back", func(t *testing.T) { + t.Setenv("ENGRAM_PORT", "not-a-number") + if got := resolveDaemonProbePort(); got != 7437 { + t.Fatalf("port = %d, want 7437", got) + } + }) + t.Run("out of range high falls back", func(t *testing.T) { + t.Setenv("ENGRAM_PORT", "65536") + if got := resolveDaemonProbePort(); got != 7437 { + t.Fatalf("port = %d, want 7437", got) + } + }) + t.Run("zero falls back", func(t *testing.T) { + t.Setenv("ENGRAM_PORT", "0") + if got := resolveDaemonProbePort(); got != 7437 { + t.Fatalf("port = %d, want 7437", got) + } + }) +} + +func TestProbeLocalDaemonRunning(t *testing.T) { + server := httptest.NewServer(httpHandlerWithStatus(http.StatusOK)) + defer server.Close() + + port := server.Listener.Addr().(*net.TCPAddr).Port + res := probeLocalDaemon(context.Background(), port) + if res.Status != daemonProbeRunning { + t.Fatalf("status = %q, want %q", res.Status, daemonProbeRunning) + } + if res.Port != port { + t.Fatalf("port = %d, want %d", res.Port, port) + } +} + +func TestProbeLocalDaemonNotRunning(t *testing.T) { + server := httptest.NewServer(httpHandlerWithStatus(http.StatusOK)) + server.Close() + + port := server.Listener.Addr().(*net.TCPAddr).Port + res := probeLocalDaemon(context.Background(), port) + if res.Status != daemonProbeNotRunning { + t.Fatalf("status = %q, want %q", res.Status, daemonProbeNotRunning) + } +} + +func TestProbeLocalDaemonUnreachable(t *testing.T) { + server := httptest.NewServer(httpHandlerWithStatus(http.StatusInternalServerError)) + defer server.Close() + + port := server.Listener.Addr().(*net.TCPAddr).Port + res := probeLocalDaemon(context.Background(), port) + if res.Status != daemonProbeUnreachable { + t.Fatalf("status = %q, want %q", res.Status, daemonProbeUnreachable) + } +} + +func TestProbeLocalDaemonTransportInjectable(t *testing.T) { + orig := daemonProbeTransport + daemonProbeTransport = &fakePingTransport{statusCode: http.StatusOK} + defer func() { daemonProbeTransport = orig }() + + res := probeLocalDaemon(context.Background(), 1234) + if res.Status != daemonProbeRunning { + t.Fatalf("status = %q, want %q", res.Status, daemonProbeRunning) + } + if res.Port != 1234 { + t.Fatalf("port = %d, want 1234", res.Port) + } +} + +func TestProbeLocalDaemonDialError(t *testing.T) { + orig := daemonProbeTransport + daemonProbeTransport = &fakePingTransport{err: &net.OpError{Op: "dial", Err: errors.New("connection refused")}} + defer func() { daemonProbeTransport = orig }() + + res := probeLocalDaemon(context.Background(), 1234) + if res.Status != daemonProbeNotRunning { + t.Fatalf("status = %q, want %q", res.Status, daemonProbeNotRunning) + } +} + diff --git a/internal/tui/model.go b/internal/tui/model.go index 3b139783..8ad4fc8f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,6 +10,11 @@ package tui import ( + "context" + "fmt" + "strings" + + "github.com/Gentleman-Programming/engram/internal/cloud/constants" "github.com/Gentleman-Programming/engram/internal/setup" "github.com/Gentleman-Programming/engram/internal/store" "github.com/Gentleman-Programming/engram/internal/version" @@ -107,12 +112,22 @@ type cloudPingMsg struct { } type cloudStatusLoadedMsg struct { - serverURL string - tokenSource string - lastSync string - pendingCount int64 - lastError string - err error + serverURL string + tokenSource string + lastSync string + pendingCount int64 + lastError string + target string + authStatus string + authWarning string + authHint string + syncReadiness string + localDaemon string + daemonHint string + syncLifecycle string + syncReasonCode string + syncReasonMessage string + err error } type cloudEnrollmentItem struct { @@ -194,13 +209,23 @@ type Model struct { CloudConfigTest bool // true when the current ping is a test, not a save // Cloud status - CloudStatusServerURL string - CloudStatusTokenSource string - CloudStatusHealth string - CloudStatusLastSync string - CloudStatusPendingCount int64 - CloudStatusLastError string - CloudStatusLoading bool + CloudStatusServerURL string + CloudStatusTokenSource string + CloudStatusHealth string + CloudStatusLastSync string + CloudStatusPendingCount int64 + CloudStatusLastError string + CloudStatusLoading bool + CloudStatusTarget string + CloudStatusAuthStatus string + CloudStatusAuthWarning string + CloudStatusAuthHint string + CloudStatusSyncReadiness string + CloudStatusLocalDaemon string + CloudStatusDaemonHint string + CloudStatusSyncLifecycle string + CloudStatusSyncReasonCode string + CloudStatusSyncReasonMessage string // Cloud enrollment CloudEnrollmentItems []cloudEnrollmentItem @@ -330,11 +355,11 @@ func loadCloudStatusCmd(s *store.Store) tea.Cmd { if err != nil { return cloudStatusLoadedMsg{err: err} } - state, err := s.GetSyncState(store.DefaultSyncTargetKey) + state, err := s.GetSyncState(constants.TargetKeyCloud) if err != nil { return cloudStatusLoadedMsg{err: err} } - count, err := s.CountPendingSyncMutations(store.DefaultSyncTargetKey) + count, err := s.CountPendingSyncMutations(constants.TargetKeyCloud) if err != nil { return cloudStatusLoadedMsg{err: err} } @@ -342,12 +367,75 @@ func loadCloudStatusCmd(s *store.Store) tea.Cmd { if state.LastError != nil { lastErr = *state.LastError } + + token := effectiveCloudToken(s.DataDir()) + insecure := isInsecureNoAuth() + target := "" + authStatus := "" + authWarning := "" + authHint := "" + syncReadiness := "" + if cc.ServerURL != "" { + target = constants.TargetKeyCloud + if token != "" { + authStatus = "ready (token provided via runtime cloud config)" + syncReadiness = "ready for explicit --project sync (project must be enrolled)" + } else if insecure { + authStatus = "ready (insecure local-dev mode: ENGRAM_CLOUD_INSECURE_NO_AUTH=1)" + authWarning = "Warning: bearer auth is disabled in insecure mode; do not use in production" + syncReadiness = "ready for explicit --project sync (project must be enrolled)" + } else { + authStatus = "token not configured (client token is optional at preflight)" + authHint = "Hint: if the remote server enforces bearer auth, set ENGRAM_CLOUD_TOKEN" + syncReadiness = "ready for explicit --project sync (project must be enrolled)" + } + } + + ctx, cancel := context.WithTimeout(context.Background(), daemonProbeTimeout) + defer cancel() + port := resolveDaemonProbePort() + daemonRes := probeLocalDaemon(ctx, port) + localDaemon := "" + daemonHint := "" + switch daemonRes.Status { + case daemonProbeRunning: + localDaemon = fmt.Sprintf("running on port %d", daemonRes.Port) + case daemonProbeNotRunning: + localDaemon = fmt.Sprintf("not running on port %d", daemonRes.Port) + daemonHint = "Hint: run `engram serve` to resume autosync; on macOS see DOCS.md launchd template to keep it alive across upgrades" + default: + localDaemon = fmt.Sprintf("unreachable on port %d", daemonRes.Port) + } + + syncLifecycle := "" + syncReasonCode := "" + syncReasonMessage := "" + if state != nil { + syncLifecycle = strings.TrimSpace(state.Lifecycle) + if state.ReasonCode != nil { + syncReasonCode = strings.TrimSpace(*state.ReasonCode) + } + if state.ReasonMessage != nil { + syncReasonMessage = strings.TrimSpace(*state.ReasonMessage) + } + } + return cloudStatusLoadedMsg{ - serverURL: cc.ServerURL, - tokenSource: tokenSourceMessage(s.DataDir()), - lastSync: state.UpdatedAt, - pendingCount: count, - lastError: lastErr, + serverURL: cc.ServerURL, + tokenSource: tokenSourceMessage(s.DataDir()), + lastSync: state.UpdatedAt, + pendingCount: count, + lastError: lastErr, + target: target, + authStatus: authStatus, + authWarning: authWarning, + authHint: authHint, + syncReadiness: syncReadiness, + localDaemon: localDaemon, + daemonHint: daemonHint, + syncLifecycle: syncLifecycle, + syncReasonCode: syncReasonCode, + syncReasonMessage: syncReasonMessage, } } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 01bb38a3..aa26790e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2,8 +2,10 @@ package tui import ( "errors" + "net/http" "os" "path/filepath" + "strings" "testing" "github.com/Gentleman-Programming/engram/internal/setup" @@ -317,6 +319,114 @@ func TestLoadCloudStatusCommandHandlesSyncStateError(t *testing.T) { } } +func TestLoadCloudStatusCommandPopulatesParityFields(t *testing.T) { + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + t.Setenv("ENGRAM_CLOUD_INSECURE_NO_AUTH", "") + + origTransport := daemonProbeTransport + daemonProbeTransport = &fakePingTransport{statusCode: http.StatusOK} + defer func() { daemonProbeTransport = origTransport }() + + fx := newTestFixture(t) + if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`{"server_url":"https://cloud.example.com","token":"file-token"}`), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } + + msg := loadCloudStatusCmd(fx.store)() + loaded, ok := msg.(cloudStatusLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if loaded.target != "cloud" { + t.Fatalf("target = %q, want cloud", loaded.target) + } + if !strings.Contains(loaded.authStatus, "ready (token provided via runtime cloud config)") { + t.Fatalf("authStatus = %q", loaded.authStatus) + } + if !strings.Contains(loaded.syncReadiness, "ready for explicit --project sync") { + t.Fatalf("syncReadiness = %q", loaded.syncReadiness) + } + if !strings.Contains(loaded.localDaemon, "running on port") { + t.Fatalf("localDaemon = %q", loaded.localDaemon) + } + if loaded.authWarning != "" { + t.Fatalf("authWarning = %q, want empty", loaded.authWarning) + } + if loaded.authHint != "" { + t.Fatalf("authHint = %q, want empty", loaded.authHint) + } + if loaded.syncLifecycle == "" { + t.Fatal("syncLifecycle should not be empty") + } +} + +func TestLoadCloudStatusCommandParityFieldsNoToken(t *testing.T) { + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + t.Setenv("ENGRAM_CLOUD_INSECURE_NO_AUTH", "") + + origTransport := daemonProbeTransport + daemonProbeTransport = &fakePingTransport{statusCode: http.StatusOK} + defer func() { daemonProbeTransport = origTransport }() + + fx := newTestFixture(t) + if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`{"server_url":"https://cloud.example.com"}`), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } + + msg := loadCloudStatusCmd(fx.store)() + loaded, ok := msg.(cloudStatusLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if !strings.Contains(loaded.authStatus, "token not configured") { + t.Fatalf("authStatus = %q", loaded.authStatus) + } + if loaded.authHint == "" { + t.Fatal("authHint should be set when no token") + } + if loaded.authWarning != "" { + t.Fatalf("authWarning = %q, want empty", loaded.authWarning) + } +} + +func TestLoadCloudStatusCommandParityFieldsInsecure(t *testing.T) { + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + t.Setenv("ENGRAM_CLOUD_INSECURE_NO_AUTH", "1") + + origTransport := daemonProbeTransport + daemonProbeTransport = &fakePingTransport{statusCode: http.StatusOK} + defer func() { daemonProbeTransport = origTransport }() + + fx := newTestFixture(t) + if err := os.WriteFile(filepath.Join(fx.store.DataDir(), "cloud.json"), []byte(`{"server_url":"https://cloud.example.com"}`), 0o644); err != nil { + t.Fatalf("write cloud.json: %v", err) + } + + msg := loadCloudStatusCmd(fx.store)() + loaded, ok := msg.(cloudStatusLoadedMsg) + if !ok { + t.Fatalf("message type = %T", msg) + } + if loaded.err != nil { + t.Fatalf("unexpected error: %v", loaded.err) + } + if !strings.Contains(loaded.authStatus, "insecure local-dev mode") { + t.Fatalf("authStatus = %q", loaded.authStatus) + } + if loaded.authWarning == "" { + t.Fatal("authWarning should be set in insecure mode") + } + if loaded.authHint != "" { + t.Fatalf("authHint = %q, want empty in insecure mode", loaded.authHint) + } +} + func TestLoadCloudEnrollmentCommand(t *testing.T) { fx := newTestFixture(t) if err := fx.store.EnrollProject("engram"); err != nil { diff --git a/internal/tui/update.go b/internal/tui/update.go index 25c420b6..8662b5f1 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -145,6 +145,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.CloudStatusLastSync = msg.lastSync m.CloudStatusPendingCount = msg.pendingCount m.CloudStatusLastError = msg.lastError + m.CloudStatusTarget = msg.target + m.CloudStatusAuthStatus = msg.authStatus + m.CloudStatusAuthWarning = msg.authWarning + m.CloudStatusAuthHint = msg.authHint + m.CloudStatusSyncReadiness = msg.syncReadiness + m.CloudStatusLocalDaemon = msg.localDaemon + m.CloudStatusDaemonHint = msg.daemonHint + m.CloudStatusSyncLifecycle = msg.syncLifecycle + m.CloudStatusSyncReasonCode = msg.syncReasonCode + m.CloudStatusSyncReasonMessage = msg.syncReasonMessage if m.CloudStatusServerURL != "" && m.store != nil { // Reuse the existing ping helper (validated by T-03) to check health. return m, pingCloudServer(m.CloudStatusServerURL, effectiveCloudToken(m.store.DataDir())) @@ -718,9 +728,9 @@ func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) { m.CloudConfigPingStatus = "" m.CloudConfigSaving = false m.CloudConfigTest = false - m.CloudConfigInput.SetValue("") - m.CloudConfigInput.Focus() - return m, loadCloudConfigCmd(m.store.DataDir()) + m.CloudConfigInput.SetValue("") + m.CloudConfigInput.Focus() + return m, loadCloudConfigCmd(m.store.DataDir()) case 1: // View status m.PrevScreen = ScreenCloudSettings m.Screen = ScreenCloudStatus @@ -732,8 +742,19 @@ func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) { m.CloudStatusLastSync = "" m.CloudStatusPendingCount = 0 m.CloudStatusLastError = "" + m.CloudStatusTarget = "" + m.CloudStatusAuthStatus = "" + m.CloudStatusAuthWarning = "" + m.CloudStatusAuthHint = "" + m.CloudStatusSyncReadiness = "" + m.CloudStatusLocalDaemon = "" + m.CloudStatusDaemonHint = "" + m.CloudStatusSyncLifecycle = "" + m.CloudStatusSyncReasonCode = "" + m.CloudStatusSyncReasonMessage = "" return m, loadCloudStatusCmd(m.store) case 2: // Enroll projects + m.PrevScreen = ScreenCloudSettings m.Screen = ScreenCloudEnrollment m.Cursor = 0 diff --git a/internal/tui/view.go b/internal/tui/view.go index 92632ceb..2ba8d30e 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -252,22 +252,71 @@ func (m Model) viewCloudStatus() string { return b.String() } - b.WriteString(renderCloudStatusRow("Server URL:", m.CloudStatusServerURL)) - b.WriteString(renderCloudStatusRow("Health:", renderCloudHealth(m.CloudStatusHealth))) - b.WriteString(renderCloudStatusRow("Token source:", m.CloudStatusTokenSource)) - b.WriteString(renderCloudStatusRow("Last sync:", m.CloudStatusLastSync)) - b.WriteString(renderCloudStatusRow("Pending:", fmt.Sprintf("%d", m.CloudStatusPendingCount))) + // Wider label column so the new parity labels (e.g. "Sync diagnostic:") + // stay on a single line without affecting other screens. + labelStyle := detailLabelStyle.Width(18) + renderRow := func(label, value string) string { + return fmt.Sprintf("%s %s\n", labelStyle.Render(label), detailValueStyle.Render(value)) + } + + if m.CloudStatusTarget != "" { + b.WriteString(renderRow("Cloud status:", fmt.Sprintf("configured (target=%s)", m.CloudStatusTarget))) + } + b.WriteString(renderRow("Server URL:", m.CloudStatusServerURL)) + if m.CloudStatusAuthStatus != "" { + b.WriteString(renderRow("Auth status:", m.CloudStatusAuthStatus)) + } + if m.CloudStatusSyncReadiness != "" { + b.WriteString(renderRow("Sync readiness:", m.CloudStatusSyncReadiness)) + } + if m.CloudStatusLocalDaemon != "" { + b.WriteString(renderRow("Local daemon:", m.CloudStatusLocalDaemon)) + } + if m.CloudStatusAuthWarning != "" { + b.WriteString(renderRow("", m.CloudStatusAuthWarning)) + } + if m.CloudStatusAuthHint != "" { + b.WriteString(renderRow("", m.CloudStatusAuthHint)) + } + if m.CloudStatusDaemonHint != "" { + b.WriteString(renderRow("", m.CloudStatusDaemonHint)) + } + + b.WriteString("\n") + + b.WriteString(renderRow("Health:", renderCloudHealth(m.CloudStatusHealth))) + b.WriteString(renderRow("Token source:", m.CloudStatusTokenSource)) + b.WriteString(renderRow("Last sync:", m.CloudStatusLastSync)) + b.WriteString(renderRow("Pending:", fmt.Sprintf("%d", m.CloudStatusPendingCount))) lastError := m.CloudStatusLastError if lastError == "" { lastError = "none" } - b.WriteString(renderCloudStatusRow("Last error:", lastError)) + b.WriteString(renderRow("Last error:", lastError)) + + m.renderCloudStatusSyncDiagnostic(&b, renderRow) b.WriteString(helpStyle.Render("\n esc/q back")) return b.String() } +func (m Model) renderCloudStatusSyncDiagnostic(b *strings.Builder, renderRow func(string, string) string) { + if m.CloudStatusSyncLifecycle == "" && m.CloudStatusSyncReasonCode == "" && m.CloudStatusSyncReasonMessage == "" { + return + } + b.WriteString("\n") + if m.CloudStatusSyncLifecycle != "" { + b.WriteString(renderRow("Sync diagnostic:", m.CloudStatusSyncLifecycle)) + } + if m.CloudStatusSyncReasonCode != "" { + b.WriteString(renderRow(" reason_code:", m.CloudStatusSyncReasonCode)) + } + if m.CloudStatusSyncReasonMessage != "" { + b.WriteString(renderRow(" reason_message:", m.CloudStatusSyncReasonMessage)) + } +} + func (m Model) viewCloudEnrollment() string { var b strings.Builder diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index bab0a5df..80510e81 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -235,6 +235,155 @@ func TestViewCloudStatusHealthAndTokenBranches(t *testing.T) { } } +func TestViewCloudStatusParityFieldsConfigured(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "https://cloud.example.com" + m.CloudStatusTarget = "cloud" + m.CloudStatusAuthStatus = "ready (token provided via runtime cloud config)" + m.CloudStatusSyncReadiness = "ready for explicit --project sync (project must be enrolled)" + m.CloudStatusLocalDaemon = "running on port 7437" + m.CloudStatusHealth = "reachable" + m.CloudStatusTokenSource = TokenSourceEnv + m.CloudStatusLastSync = "2026-07-07 12:00:00" + m.CloudStatusPendingCount = 2 + m.CloudStatusLastError = "" + + out := m.View() + for _, want := range []string{"Cloud status:", "configured (target=cloud)"} { + if !strings.Contains(out, want) { + t.Fatalf("configured cloud status missing %q, got %q", want, out) + } + } + for _, want := range []string{"Auth status:", "ready (token provided via runtime cloud config)"} { + if !strings.Contains(out, want) { + t.Fatalf("auth status missing %q, got %q", want, out) + } + } + for _, want := range []string{"Sync readiness:", "ready for explicit --project sync (project must be enrolled)"} { + if !strings.Contains(out, want) { + t.Fatalf("sync readiness missing %q, got %q", want, out) + } + } + for _, want := range []string{"Local daemon:", "running on port 7437"} { + if !strings.Contains(out, want) { + t.Fatalf("local daemon missing %q, got %q", want, out) + } + } + + // Existing fields must still render. + for _, want := range []string{"Server URL:", "Health:", "Token source:", "Last sync:", "Pending:", "Last error:"} { + if !strings.Contains(out, want) { + t.Fatalf("existing label %q missing from view, got %q", want, out) + } + } +} + +func TestViewCloudStatusParityFieldsNoToken(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "https://cloud.example.com" + m.CloudStatusTarget = "cloud" + m.CloudStatusAuthStatus = "token not configured (client token is optional at preflight)" + m.CloudStatusAuthHint = "Hint: if the remote server enforces bearer auth, set ENGRAM_CLOUD_TOKEN" + m.CloudStatusSyncReadiness = "ready for explicit --project sync (project must be enrolled)" + m.CloudStatusLocalDaemon = "not running on port 7437" + m.CloudStatusDaemonHint = "Hint: run `engram serve` to resume autosync; on macOS see DOCS.md launchd template to keep it alive across upgrades" + m.CloudStatusHealth = "reachable" + m.CloudStatusTokenSource = TokenSourceNone + + out := m.View() + for _, want := range []string{"Auth status:", "token not configured (client token is optional at preflight)"} { + if !strings.Contains(out, want) { + t.Fatalf("no-token auth status missing %q, got %q", want, out) + } + } + if !strings.Contains(out, "Hint: if the remote server enforces bearer auth, set ENGRAM_CLOUD_TOKEN") { + t.Fatalf("view should render auth hint, got %q", out) + } + if !strings.Contains(out, "not running on port 7437") { + t.Fatalf("view should render daemon not running, got %q", out) + } + if !strings.Contains(out, "Hint: run `engram serve` to resume autosync") { + t.Fatalf("view should render daemon hint, got %q", out) + } +} + +func TestViewCloudStatusParityFieldsInsecure(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "https://cloud.example.com" + m.CloudStatusTarget = "cloud" + m.CloudStatusAuthStatus = "ready (insecure local-dev mode: ENGRAM_CLOUD_INSECURE_NO_AUTH=1)" + m.CloudStatusAuthWarning = "Warning: bearer auth is disabled in insecure mode; do not use in production" + m.CloudStatusSyncReadiness = "ready for explicit --project sync (project must be enrolled)" + m.CloudStatusLocalDaemon = "unreachable on port 7437" + m.CloudStatusHealth = "reachable" + m.CloudStatusTokenSource = TokenSourceNone + + out := m.View() + for _, want := range []string{"Auth status:", "ready (insecure local-dev mode: ENGRAM_CLOUD_INSECURE_NO_AUTH=1)"} { + if !strings.Contains(out, want) { + t.Fatalf("insecure auth status missing %q, got %q", want, out) + } + } + if !strings.Contains(out, "Warning: bearer auth is disabled in insecure mode; do not use in production") { + t.Fatalf("view should render insecure warning, got %q", out) + } + if !strings.Contains(out, "unreachable on port 7437") { + t.Fatalf("view should render daemon unreachable, got %q", out) + } +} + +func TestViewCloudStatusSyncDiagnostic(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "https://cloud.example.com" + m.CloudStatusTarget = "cloud" + m.CloudStatusAuthStatus = "ready (token provided via runtime cloud config)" + m.CloudStatusSyncReadiness = "ready for explicit --project sync (project must be enrolled)" + m.CloudStatusLocalDaemon = "running on port 7437" + m.CloudStatusHealth = "reachable" + m.CloudStatusTokenSource = TokenSourceEnv + m.CloudStatusSyncLifecycle = "degraded" + m.CloudStatusSyncReasonCode = "auth_required" + m.CloudStatusSyncReasonMessage = "token missing" + + out := m.View() + if !strings.Contains(out, "Sync diagnostic:") { + t.Fatalf("view should render sync diagnostic label, got %q", out) + } + if !strings.Contains(out, "degraded") { + t.Fatalf("view should render lifecycle, got %q", out) + } + if !strings.Contains(out, "auth_required") { + t.Fatalf("view should render reason_code, got %q", out) + } + if !strings.Contains(out, "token missing") { + t.Fatalf("view should render reason_message, got %q", out) + } +} + +func TestViewCloudStatusSyncDiagnosticOmittedWhenEmpty(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenCloudStatus + m.CloudStatusServerURL = "https://cloud.example.com" + m.CloudStatusTarget = "cloud" + m.CloudStatusAuthStatus = "ready (token provided via runtime cloud config)" + m.CloudStatusSyncReadiness = "ready for explicit --project sync (project must be enrolled)" + m.CloudStatusLocalDaemon = "running on port 7437" + m.CloudStatusHealth = "reachable" + m.CloudStatusTokenSource = TokenSourceEnv + m.CloudStatusSyncLifecycle = "" + m.CloudStatusSyncReasonCode = "" + m.CloudStatusSyncReasonMessage = "" + + out := m.View() + if strings.Contains(out, "Sync diagnostic:") { + t.Fatalf("view should omit sync diagnostic when empty, got %q", out) + } +} + func TestViewRouterAndErrorRendering(t *testing.T) { m := New(nil, "") m.Screen = Screen(999)