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. --- diff --git a/internal/tui/model.go b/internal/tui/model.go index 536d97d7..41d38ed8 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -36,6 +36,7 @@ const ( ScreenSessions ScreenSessionDetail ScreenSetup + ScreenCloudSettings ) type SessionDeleteState int 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 c8f09b11..896ba970 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -193,6 +193,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 } @@ -204,9 +206,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": @@ -264,7 +274,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 @@ -643,6 +658,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 ───────────────────────────────────────────────────────────────── func (m Model) resetSessionDeleteState() Model { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 2e9eaccd..fe5b4ac4 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -211,6 +211,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, "") @@ -617,6 +727,7 @@ func TestHandleKeyPressRouterAndClearsError(t *testing.T) { ScreenSessions, ScreenSessionDetail, ScreenSetup, + ScreenCloudSettings, } { m.Screen = screen m.ErrorMsg = "old error" @@ -643,7 +754,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") @@ -661,10 +772,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 48740041..7b0c7fed 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 31317182..614411d0 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -384,6 +384,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 {