Skip to content

Commit ba9e46c

Browse files
feat(tui): add cloud settings screen and docs (#607)
* feat(tui): add cloud settings screen and dashboard entry * docs(cloud): document cloud.json.token fallback policy --------- Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
1 parent ea8473c commit ba9e46c

7 files changed

Lines changed: 209 additions & 11 deletions

File tree

docs/engram-cloud/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Cloud auth token is runtime config:
4949
export ENGRAM_CLOUD_TOKEN="your-token"
5050
```
5151

52-
The local `~/.engram/cloud.json` stores the server URL. The token is intentionally read from the environment.
52+
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.
5353

5454
---
5555

internal/tui/model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const (
3636
ScreenSessions
3737
ScreenSessionDetail
3838
ScreenSetup
39+
ScreenCloudSettings
3940
)
4041

4142
type SessionDeleteState int

internal/tui/model_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ func TestNewInitializesModelDefaults(t *testing.T) {
8686
}
8787
}
8888

89+
func TestScreenCloudSettingsConstant(t *testing.T) {
90+
if ScreenCloudSettings != ScreenSetup+1 {
91+
t.Fatalf("ScreenCloudSettings = %d, want %d (ScreenSetup+1)", ScreenCloudSettings, ScreenSetup+1)
92+
}
93+
94+
seen := map[Screen]bool{}
95+
for _, s := range []Screen{
96+
ScreenDashboard,
97+
ScreenSearch,
98+
ScreenSearchResults,
99+
ScreenRecent,
100+
ScreenObservationDetail,
101+
ScreenTimeline,
102+
ScreenSessions,
103+
ScreenSessionDetail,
104+
ScreenSetup,
105+
ScreenCloudSettings,
106+
} {
107+
if seen[s] {
108+
t.Fatalf("screen constant %d is duplicated", s)
109+
}
110+
seen[s] = true
111+
}
112+
}
113+
89114
func TestInitReturnsCommand(t *testing.T) {
90115
m := New(newTestFixture(t).store, "")
91116
if cmd := m.Init(); cmd == nil {

internal/tui/update.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ func (m Model) handleKeyPress(key string) (tea.Model, tea.Cmd) {
193193
return m.handleSessionDetailKeys(key)
194194
case ScreenSetup:
195195
return m.handleSetupKeys(key)
196+
case ScreenCloudSettings:
197+
return m.handleCloudSettingsKeys(key)
196198
}
197199
return m, nil
198200
}
@@ -204,9 +206,17 @@ var dashboardMenuItems = []string{
204206
"Recent observations",
205207
"Browse sessions",
206208
"Setup agent plugin",
209+
"Cloud sync settings",
207210
"Quit",
208211
}
209212

213+
var cloudSettingsMenuItems = []string{
214+
"Configure server",
215+
"View status",
216+
"Enroll projects",
217+
"Back",
218+
}
219+
210220
func (m Model) handleDashboardKeys(key string) (tea.Model, tea.Cmd) {
211221
switch key {
212222
case "up", "k":
@@ -264,7 +274,12 @@ func (m Model) handleDashboardSelection() (tea.Model, tea.Cmd) {
264274
m.SetupInstalling = false
265275
m.SetupInstallingName = ""
266276
return m, nil
267-
case 4: // Quit
277+
case 4: // Cloud sync settings
278+
m.PrevScreen = ScreenDashboard
279+
m.Screen = ScreenCloudSettings
280+
m.Cursor = 0
281+
return m, nil
282+
case 5: // Quit
268283
return m, tea.Quit
269284
}
270285
return m, nil
@@ -643,6 +658,32 @@ func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) {
643658
return m, nil
644659
}
645660

661+
// ─── Cloud Settings ──────────────────────────────────────────────────────────
662+
663+
func (m Model) handleCloudSettingsKeys(key string) (tea.Model, tea.Cmd) {
664+
switch key {
665+
case "up", "k":
666+
if m.Cursor > 0 {
667+
m.Cursor--
668+
}
669+
case "down", "j":
670+
if m.Cursor < len(cloudSettingsMenuItems)-1 {
671+
m.Cursor++
672+
}
673+
case "enter", " ":
674+
if m.Cursor == len(cloudSettingsMenuItems)-1 { // Back
675+
m.Screen = ScreenDashboard
676+
m.Cursor = 0
677+
return m, loadStats(m.store)
678+
}
679+
case "esc", "q":
680+
m.Screen = ScreenDashboard
681+
m.Cursor = 0
682+
return m, loadStats(m.store)
683+
}
684+
return m, nil
685+
}
686+
646687
// ─── Helpers ─────────────────────────────────────────────────────────────────
647688

648689
func (m Model) resetSessionDeleteState() Model {

internal/tui/update_test.go

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,116 @@ func TestHandleDashboardAndSearchKeyPaths(t *testing.T) {
211211
}
212212
}
213213

214+
func TestDashboardHasCloudSettingsMenuItem(t *testing.T) {
215+
cloudIdx, quitIdx := -1, -1
216+
for i, item := range dashboardMenuItems {
217+
if item == "Cloud sync settings" {
218+
cloudIdx = i
219+
}
220+
if item == "Quit" {
221+
quitIdx = i
222+
}
223+
}
224+
if cloudIdx < 0 {
225+
t.Fatal("dashboard menu is missing Cloud sync settings item")
226+
}
227+
if quitIdx < 0 {
228+
t.Fatal("dashboard menu is missing Quit item")
229+
}
230+
if cloudIdx >= quitIdx {
231+
t.Fatalf("Cloud sync settings (%d) must appear before Quit (%d)", cloudIdx, quitIdx)
232+
}
233+
}
234+
235+
func TestCloudSettingsNavigation(t *testing.T) {
236+
m := New(nil, "")
237+
m.Cursor = 4 // Cloud sync settings
238+
239+
updatedModel, _ := m.handleDashboardSelection()
240+
updated := updatedModel.(Model)
241+
if updated.Screen != ScreenCloudSettings {
242+
t.Fatalf("enter on Cloud sync settings should open ScreenCloudSettings, got %v", updated.Screen)
243+
}
244+
if updated.Cursor != 0 {
245+
t.Fatalf("cursor should reset to 0 on entering cloud settings, got %d", updated.Cursor)
246+
}
247+
248+
updatedModel, _ = updated.handleCloudSettingsKeys("esc")
249+
updated = updatedModel.(Model)
250+
if updated.Screen != ScreenDashboard {
251+
t.Fatalf("esc from cloud settings should return to dashboard, got %v", updated.Screen)
252+
}
253+
254+
m = New(nil, "")
255+
m.Screen = ScreenCloudSettings
256+
updatedModel, _ = m.handleCloudSettingsKeys("q")
257+
updated = updatedModel.(Model)
258+
if updated.Screen != ScreenDashboard {
259+
t.Fatalf("q from cloud settings should return to dashboard, got %v", updated.Screen)
260+
}
261+
}
262+
263+
func TestCloudSettingsMenuNavigation(t *testing.T) {
264+
m := New(nil, "")
265+
m.Screen = ScreenCloudSettings
266+
267+
updatedModel, _ := m.handleCloudSettingsKeys("down")
268+
updated := updatedModel.(Model)
269+
if updated.Cursor != 1 {
270+
t.Fatalf("down should move cursor to 1, got %d", updated.Cursor)
271+
}
272+
273+
updatedModel, _ = updated.handleCloudSettingsKeys("j")
274+
updated = updatedModel.(Model)
275+
if updated.Cursor != 2 {
276+
t.Fatalf("j should move cursor to 2, got %d", updated.Cursor)
277+
}
278+
279+
updatedModel, _ = updated.handleCloudSettingsKeys("down")
280+
updated = updatedModel.(Model)
281+
if updated.Cursor != 3 {
282+
t.Fatalf("down should move cursor to 3, got %d", updated.Cursor)
283+
}
284+
285+
updatedModel, _ = updated.handleCloudSettingsKeys("down")
286+
updated = updatedModel.(Model)
287+
if updated.Cursor != 3 {
288+
t.Fatalf("down at bottom should stay at 3, got %d", updated.Cursor)
289+
}
290+
291+
updatedModel, _ = updated.handleCloudSettingsKeys("up")
292+
updated = updatedModel.(Model)
293+
if updated.Cursor != 2 {
294+
t.Fatalf("up should move cursor to 2, got %d", updated.Cursor)
295+
}
296+
297+
updatedModel, _ = updated.handleCloudSettingsKeys("k")
298+
updated = updatedModel.(Model)
299+
if updated.Cursor != 1 {
300+
t.Fatalf("k should move cursor to 1, got %d", updated.Cursor)
301+
}
302+
303+
m = New(nil, "")
304+
m.Screen = ScreenCloudSettings
305+
updatedModel, _ = m.handleCloudSettingsKeys("up")
306+
updated = updatedModel.(Model)
307+
if updated.Cursor != 0 {
308+
t.Fatalf("up at top should stay at 0, got %d", updated.Cursor)
309+
}
310+
311+
m = New(nil, "")
312+
m.Screen = ScreenCloudSettings
313+
m.Cursor = 3 // Back
314+
updatedModel, cmd := m.handleCloudSettingsKeys("enter")
315+
updated = updatedModel.(Model)
316+
if updated.Screen != ScreenDashboard {
317+
t.Fatalf("enter on Back should return to dashboard, got %v", updated.Screen)
318+
}
319+
if cmd == nil {
320+
t.Fatal("enter on Back should refresh stats")
321+
}
322+
}
323+
214324
func TestHandleRecentTimelineSessionsAndDetailKeyPaths(t *testing.T) {
215325
fx := newTestFixture(t)
216326
m := New(fx.store, "")
@@ -617,6 +727,7 @@ func TestHandleKeyPressRouterAndClearsError(t *testing.T) {
617727
ScreenSessions,
618728
ScreenSessionDetail,
619729
ScreenSetup,
730+
ScreenCloudSettings,
620731
} {
621732
m.Screen = screen
622733
m.ErrorMsg = "old error"
@@ -643,7 +754,7 @@ func TestHandleDashboardKeysAndSelectionRemainingBranches(t *testing.T) {
643754
t.Fatal("cursor should stay at bottom boundary")
644755
}
645756

646-
m.Cursor = 4
757+
m.Cursor = 5
647758
_, cmd := m.handleDashboardKeys(" ")
648759
if cmd == nil {
649760
t.Fatal("space on quit item should return quit command")
@@ -661,10 +772,10 @@ func TestHandleDashboardKeysAndSelectionRemainingBranches(t *testing.T) {
661772
t.Fatal("cursor 0 selection should open search")
662773
}
663774

664-
m.Cursor = 4
775+
m.Cursor = 5
665776
_, cmd = m.handleDashboardSelection()
666777
if cmd == nil {
667-
t.Fatal("cursor 4 selection should quit")
778+
t.Fatal("cursor 5 selection should quit")
668779
}
669780

670781
m.Cursor = 99

internal/tui/view.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ func (m Model) View() string {
8080
content = m.viewSessionDetail()
8181
case ScreenSetup:
8282
content = m.viewSetup()
83+
case ScreenCloudSettings:
84+
content = m.viewCloudSettings()
8385
default:
8486
content = "Unknown screen"
8587
}
@@ -159,19 +161,36 @@ func (m Model) viewDashboard() string {
159161
// Menu
160162
b.WriteString(titleStyle.Render(" Actions"))
161163
b.WriteString("\n")
164+
b.WriteString(renderMenu(dashboardMenuItems, m.Cursor))
162165

163-
for i, item := range dashboardMenuItems {
164-
if i == m.Cursor {
166+
// Help
167+
b.WriteString(helpStyle.Render("\n j/k navigate • enter select • s search • q quit"))
168+
169+
return b.String()
170+
}
171+
172+
func (m Model) viewCloudSettings() string {
173+
var b strings.Builder
174+
175+
b.WriteString(headerStyle.Render(" Cloud sync settings"))
176+
b.WriteString("\n\n")
177+
b.WriteString(renderMenu(cloudSettingsMenuItems, m.Cursor))
178+
b.WriteString(helpStyle.Render("\n j/k navigate • enter select • esc/q back"))
179+
180+
return b.String()
181+
}
182+
183+
// renderMenu renders a vertical list of selectable menu items with a cursor.
184+
func renderMenu(items []string, cursor int) string {
185+
var b strings.Builder
186+
for i, item := range items {
187+
if i == cursor {
165188
b.WriteString(menuSelectedStyle.Render("▸ " + item))
166189
} else {
167190
b.WriteString(menuItemStyle.Render(" " + item))
168191
}
169192
b.WriteString("\n")
170193
}
171-
172-
// Help
173-
b.WriteString(helpStyle.Render("\n j/k navigate • enter select • s search • q quit"))
174-
175194
return b.String()
176195
}
177196

internal/tui/view_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ func TestViewRouterCoversAllScreens(t *testing.T) {
384384
{screen: ScreenSessions, want: "Sessions"},
385385
{screen: ScreenSessionDetail, want: "Session:"},
386386
{screen: ScreenSetup, want: "Setup"},
387+
{screen: ScreenCloudSettings, want: "Cloud sync settings"},
387388
}
388389

389390
for _, tt := range tests {

0 commit comments

Comments
 (0)