From 95b99eaaf97823515ce53b69f43c0966984ba047 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Tue, 7 Jul 2026 23:03:24 -0600 Subject: [PATCH 1/2] 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 2/2] 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. ---