diff --git a/README.md b/README.md index 88e0bf0..86f9a88 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ unic --checklist ./checklists/readiness.yaml # optional: pre-load a checklist unic --verbose ``` +The TUI shows a short retro bootup splash after a new install or version update, then records the version so later launches go straight to the context picker. Press `Enter`, `Esc`, or `Space` to skip it. Press `S` to open Settings, where the splash can be toggled on or off for every launch. + ### Config/bootstrap ```bash @@ -146,6 +148,10 @@ favorites: - Bedrock - ECS +ui: + boot_splash: false + last_boot_splash_version: 0.1.3 + contexts: - name: dev-sso order: 10 @@ -323,6 +329,7 @@ checks: | `H` | Jump to service list | | `i` | Enter Inspector mode from the service list | | `C` | Open context picker | +| `S` | Open settings | | `/` | Toggle filter mode on supported screens | | `f` | Favorite/unfavorite the selected service on the service list | | `?` | Toggle context-aware shortcut help | @@ -350,6 +357,7 @@ checks: | Inspector Mode | `i` open mode from the service list, `Enter` open the selected workflow, `l` open the checklist file picker | | Security Inspector | `r` run/rescan, `1`-`5` severity filter, `Enter` finding detail | | Checklist Inspector | `l` load or switch checklist files, `r` run/rerun the loaded checklist, `Enter` result detail | +| Settings | `Enter`/`Space` toggle selected setting, `Esc`/`q` back | | Context Picker | `a` add context, type or `/` filter, `s` setup selected context and quit, `y` copy selected exports and quit, filter-mode `Ctrl+S` setup selected filtered context, filter-mode `Ctrl+Y` copy selected filtered exports, `u` clear shell context and quit with a final confirmation message | | ECR | `Enter` images, `d` repository detail, `/` filter, `r` refresh, image detail `c` copy digest, `t` copy tag | | Lambda | `Enter` invoke, `d` detail, `l` view CloudWatch Logs, `/` filter, `r` refresh | diff --git a/internal/app/app.go b/internal/app/app.go index 869c162..66d95f4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" @@ -19,7 +20,8 @@ import ( type screen int const ( - screenServiceList screen = iota + screenBootup screen = iota + screenServiceList screenFeatureList screenInstanceList screenEC2InstanceBrowserList @@ -104,6 +106,7 @@ const ( screenContextAdd screenContextSSOAccountList screenContextSSORoleList + screenSettings screenLoading screenError screenExitNotice @@ -111,12 +114,15 @@ const ( // Model is the root Bubbletea model. type Model struct { - cfg *config.Config - awsRepo *awsservice.AwsRepository - screen screen - quitting bool - exitMessage string - exitTitle string + cfg *config.Config + awsRepo *awsservice.AwsRepository + screen screen + quitting bool + exitMessage string + exitTitle string + bootFrame int + settingsIdx int + settingsPrevScreen screen // App-shell state stays root-owned because it coordinates global navigation, // context/session setup, shared chrome, and cross-feature transitions. @@ -193,6 +199,7 @@ type Model struct { currentVersion string updateAvailable string // non-empty = new version available installMethod update.InstallMethod + bootSplash bool // Error display errMsg string @@ -231,6 +238,7 @@ func New(cfg *config.Config, configPath string, version string, checklistPath .. ctxPrevScreen: screenServiceList, services: services, favoriteServices: favoriteServiceSet(favoriteServiceNames), + bootSplash: cfg != nil && cfg.BootSplash, loadingSpinner: newLoadingSpinner(), filterTI: filterTI, filters: make(map[filterTarget]string), @@ -284,9 +292,55 @@ func (m Model) checkForUpdate() tea.Cmd { } func (m Model) Init() tea.Cmd { - return tea.Batch(m.loadContexts(), m.checkForUpdate(), m.loadStartupCallerIdentity()) + cmds := []tea.Cmd{m.loadStartupContexts(), m.checkForUpdate(), m.loadStartupCallerIdentity()} + if m.shouldShowBootup() { + cmds = append(cmds, bootupTickCmd(), m.markBootupSeen()) + return tea.Sequence( + func() tea.Msg { return bootupStartMsg{} }, + tea.Batch(cmds...), + ) + } + return tea.Sequence( + func() tea.Msg { return screenReadyMsg{} }, + tea.Batch(cmds...), + ) +} + +func (m Model) shouldShowBootup() bool { + if m.bootSplash { + return true + } + if m.cfg == nil { + return false + } + return strings.TrimSpace(m.currentVersion) != "" && m.cfg.BootSplashSeen != m.currentVersion +} + +func bootupTickCmd() tea.Cmd { + return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { + return bootupTickMsg{} + }) } +func (m Model) markBootupSeen() tea.Cmd { + return func() tea.Msg { + if m.configPath == "" || strings.TrimSpace(m.currentVersion) == "" { + return nil + } + if err := configSetBootSplashSeenVersionFn(m.configPath, m.currentVersion); err != nil { + return nil + } + if m.cfg != nil { + m.cfg.BootSplashSeen = m.currentVersion + } + return nil + } +} + +// loadStartupCallerIdentity resolves the caller identity at startup without +// triggering an interactive SSO login. If the active context uses SSO and its +// session has expired (or the check fails), it skips identity display rather +// than blocking startup on a browser-based login prompt. func (m Model) loadStartupCallerIdentity() tea.Cmd { return func() tea.Msg { if m.cfg == nil { @@ -333,6 +387,25 @@ func (m Model) startLoadingWithMessage(title string, details []string, cmd tea.C return m, tea.Batch(m.loadingSpinner.Tick, cmd) } +// isTextEntryScreen reports whether the current screen captures free-form text +// (or confirmation) input, so global single-letter shortcuts like S must not +// fire and steal a keystroke from the active field. +func (m Model) isTextEntryScreen() bool { + switch m.screen { + case screenContextAdd, + screenRoute53RecordCreate, + screenRoute53RecordEdit, + screenSecurityGroupAddRule, + screenSecurityGroupDeleteConfirm, + screenLambdaInvokeInput, + screenBedrockKeyCreate, + screenBedrockKeyConfirm: + return true + default: + return false + } +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Global messages switch msg := msg.(type) { @@ -345,6 +418,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case callerIdentityMsg: m.callerIdentity = msg.identity return m, nil + case screenReadyMsg: + m.screen = screenContextPicker + return m, nil + case bootupStartMsg: + m.screen = screenBootup + m.bootFrame = 0 + return m, nil + case bootupTickMsg: + if m.screen != screenBootup { + return m, nil + } + m.bootFrame++ + if m.bootFrame >= bootupFrameCount { + m.screen = screenContextPicker + return m, nil + } + return m, bootupTickCmd() case updateAvailableMsg: m.installMethod = msg.method if msg.version != "" { @@ -388,6 +478,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit } + if m.screen == screenBootup { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "enter", "esc", " ": + m.screen = screenContextPicker + } + return m, nil + } if m.screen == screenExitNotice { m.quitting = true return m, tea.Quit @@ -421,6 +521,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ctxPrevScreen = m.screen return m, m.loadContexts() } + // Global settings — S opens the settings screen (skip text-entry screens + // and the filter input so it never steals a typed character). + if msg.String() == "S" && !m.filterTI.Focused() && m.screen != screenSettings && + !m.isTextEntryScreen() { + m.deactivateFilter() + m.settingsPrevScreen = m.screen + m.screen = screenSettings + return m, nil + } for _, submodel := range m.featureSubmodels() { if newM, cmd, handled := submodel.HandleKey(&m, msg); handled { @@ -443,6 +552,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateContextSSOAccountList(msg) case screenContextSSORoleList: return m.updateContextSSORoleList(msg) + case screenSettings: + return m.updateSettings(msg) case screenError: return m.updateError(msg) } @@ -583,6 +694,8 @@ func (m Model) View() string { } } switch m.screen { + case screenBootup: + v = m.viewBootup() case screenServiceList: v = m.viewServiceList() case screenFeatureList: @@ -597,6 +710,8 @@ func (m Model) View() string { v = m.viewContextSSOAccountList() case screenContextSSORoleList: v = m.viewContextSSORoleList() + case screenSettings: + v = m.viewSettings() case screenLoading: v = m.viewLoading() case screenError: diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 992db36..35fdc61 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,6 +1,7 @@ package app import ( + "errors" "os" "path/filepath" "strings" @@ -151,146 +152,252 @@ func TestLoadingSpinnerTickUpdatesOnlyOnLoadingScreen(t *testing.T) { } } -func TestStartupCallerIdentitySkipsInteractiveSSOLoginWhenCacheMissing(t *testing.T) { - origCheck := contextCheckSSOSessionFn - origLoad := appLoadCallerIdentityFn - defer func() { - contextCheckSSOSessionFn = origCheck - appLoadCallerIdentityFn = origLoad - }() +func TestBootupStartShowsAnimatedSplash(t *testing.T) { + m := New(testConfig(), "", "dev") + + updated, cmd := m.Update(bootupStartMsg{}) + model := updated.(Model) + if cmd != nil { + t.Fatal("expected bootup start to only update model state") + } + if model.screen != screenBootup { + t.Fatalf("expected bootup screen, got %v", model.screen) + } - checkCalled := false - loadCalled := false - contextCheckSSOSessionFn = func(cfg *config.Config) (awsservice.SSOSessionCheck, error) { - checkCalled = true - return awsservice.SSOSessionCheck{ - StartURL: cfg.SSOStartURL, - LoginRequired: true, - }, nil - } - appLoadCallerIdentityFn = func(Model) tea.Cmd { - loadCalled = true - return func() tea.Msg { - return callerIdentityMsg{identity: &awsservice.CallerIdentity{Account: "123456789012"}} + view := stripANSI(model.View()) + for _, want := range []string{"UNIC BIOS", "UNIC", "enter/esc/space: skip"} { + if !strings.Contains(view, want) { + t.Fatalf("expected bootup view to contain %q, got %q", want, view) } } +} - m := New(&config.Config{ - AuthType: config.AuthTypeSSO, - SSOStartURL: "https://example.awsapps.com/start", - Region: "us-east-1", - }, "", "dev") +func TestBootupContextsLoadWithoutInterruptingSplash(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenBootup - msg := m.loadStartupCallerIdentity()() - if _, ok := msg.(callerIdentityMsg); !ok { - t.Fatalf("expected callerIdentityMsg, got %T", msg) + updated, _ := m.Update(contextsLoadedMsg{contexts: testContexts()}) + model := updated.(Model) + if model.screen != screenBootup { + t.Fatalf("expected context load to keep bootup screen active, got %v", model.screen) } - if !checkCalled { - t.Fatal("expected startup to check SSO cache") + if got := len(model.contextTable.Rows()); got != len(testContexts()) { + t.Fatalf("expected contexts to load behind bootup, got %d rows", got) } - if loadCalled { - t.Fatal("expected startup to skip identity loading when SSO login is required") +} + +func TestStartupContextsLoadDoesNotOverrideSettings(t *testing.T) { + m := New(testConfig(), "", "dev") + // User has opened Settings while the startup context load is still in flight. + m.screen = screenSettings + + updated, _ := m.Update(contextsLoadedMsg{contexts: testContexts(), startup: true}) + model := updated.(Model) + if model.screen != screenSettings { + t.Fatalf("startup context load should not steal navigation from Settings, got %v", model.screen) + } + if got := len(model.contextTable.Rows()); got != len(testContexts()) { + t.Fatalf("expected contexts to load behind Settings, got %d rows", got) } } -func TestStartupCallerIdentitySkipsInteractiveSSOLoginWhenSessionCheckFails(t *testing.T) { - origCheck := contextCheckSSOSessionFn - origLoad := appLoadCallerIdentityFn - defer func() { - contextCheckSSOSessionFn = origCheck - appLoadCallerIdentityFn = origLoad - }() +func TestGlobalSettingsShortcutSkipsTextEntryScreens(t *testing.T) { + m := New(testConfig(), "", "dev") + // Put the model on the context-add screen in a free-form field-input step. + m.screen = screenContextAdd + m.addStep = 1 + m.addFields = fieldsByAuthType["credential"] + m.addFieldIdx = 0 + m.addInput = "" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("S")}) + model := updated.(Model) - checkCalled := false - loadCalled := false - contextCheckSSOSessionFn = func(cfg *config.Config) (awsservice.SSOSessionCheck, error) { - checkCalled = true - return awsservice.SSOSessionCheck{}, os.ErrNotExist + if model.screen == screenSettings { + t.Fatal("uppercase S during text entry must not open Settings") } - appLoadCallerIdentityFn = func(Model) tea.Cmd { - loadCalled = true - return func() tea.Msg { - return callerIdentityMsg{identity: &awsservice.CallerIdentity{Account: "123456789012"}} + if model.screen != screenContextAdd { + t.Fatalf("expected to stay on context-add screen, got %v", model.screen) + } + if model.addInput != "S" { + t.Fatalf("expected S to be typed into the field, got %q", model.addInput) + } +} + +func TestBootupBannerReflectsVersion(t *testing.T) { + cases := map[string]string{ + "0.1.3": "UNIC BIOS v0.1.3 COPYRIGHT 1986-2026 DEVOPS ART FACTORY", + "v2.0.0": "UNIC BIOS v2.0.0 COPYRIGHT 1986-2026 DEVOPS ART FACTORY", + "": "UNIC BIOS dev COPYRIGHT 1986-2026 DEVOPS ART FACTORY", + "dev": "UNIC BIOS dev COPYRIGHT 1986-2026 DEVOPS ART FACTORY", + } + for version, want := range cases { + if got := bootupBanner(version); got != want { + t.Errorf("bootupBanner(%q) = %q, want %q", version, got, want) } } +} - m := New(&config.Config{ - AuthType: config.AuthTypeSSO, - SSOStartURL: "https://example.awsapps.com/start", - Region: "us-east-1", - }, "", "dev") +func TestBootupTickTransitionsToContextPicker(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenBootup + m.bootFrame = bootupFrameCount - 1 - msg := m.loadStartupCallerIdentity()() - if _, ok := msg.(callerIdentityMsg); !ok { - t.Fatalf("expected callerIdentityMsg, got %T", msg) + updated, cmd := m.Update(bootupTickMsg{}) + model := updated.(Model) + if cmd != nil { + t.Fatal("expected final bootup tick to stop scheduling ticks") } - if !checkCalled { - t.Fatal("expected startup to check SSO cache") + if model.screen != screenContextPicker { + t.Fatalf("expected bootup to finish at context picker, got %v", model.screen) } - if loadCalled { - t.Fatal("expected startup to skip identity loading when SSO session check fails") +} + +func TestBootupCanBeSkipped(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenBootup + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if cmd != nil { + t.Fatal("expected skip key to only update model state") + } + if model.screen != screenContextPicker { + t.Fatalf("expected skip to open context picker, got %v", model.screen) } } -func TestStartupCallerIdentityLoadsSSOIdentityWhenCacheValid(t *testing.T) { - origCheck := contextCheckSSOSessionFn - origLoad := appLoadCallerIdentityFn +func TestBootupShowsForNewVersionOnlyWhenPreferenceDisabled(t *testing.T) { + m := New(&config.Config{BootSplashSeen: "0.1.2"}, "", "0.1.3") + if !m.shouldShowBootup() { + t.Fatal("expected bootup to show after update") + } + + m = New(&config.Config{BootSplashSeen: "0.1.3"}, "", "0.1.3") + if m.shouldShowBootup() { + t.Fatal("expected bootup to stay off after version has been seen") + } +} + +func TestBootupPreferenceAlwaysShowsSplash(t *testing.T) { + m := New(&config.Config{BootSplash: true, BootSplashSeen: "0.1.3"}, "", "0.1.3") + if !m.shouldShowBootup() { + t.Fatal("expected bootup preference to force splash") + } +} + +func TestSettingsTogglesBootSplashPreference(t *testing.T) { + origSetBootSplashEnabledFn := configSetBootSplashEnabledFn defer func() { - contextCheckSSOSessionFn = origCheck - appLoadCallerIdentityFn = origLoad + configSetBootSplashEnabledFn = origSetBootSplashEnabledFn }() - contextCheckSSOSessionFn = func(cfg *config.Config) (awsservice.SSOSessionCheck, error) { - return awsservice.SSOSessionCheck{StartURL: cfg.SSOStartURL}, nil - } - appLoadCallerIdentityFn = func(Model) tea.Cmd { - return func() tea.Msg { - return callerIdentityMsg{identity: &awsservice.CallerIdentity{Account: "123456789012"}} + var gotEnabled bool + configSetBootSplashEnabledFn = func(path string, enabled bool) error { + if path != "config.yaml" { + t.Fatalf("expected config path, got %q", path) } + gotEnabled = enabled + return nil } - m := New(&config.Config{ - AuthType: config.AuthTypeSSO, - SSOStartURL: "https://example.awsapps.com/start", - Region: "us-east-1", - }, "", "dev") + cfg := testConfig() + m := New(cfg, "config.yaml", "dev") + m.screen = screenSettings - msg := m.loadStartupCallerIdentity()() - identityMsg, ok := msg.(callerIdentityMsg) - if !ok { - t.Fatalf("expected callerIdentityMsg, got %T", msg) + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if cmd != nil { + t.Fatal("expected boot splash toggle to be synchronous") } - if identityMsg.identity == nil || identityMsg.identity.Account != "123456789012" { - t.Fatalf("expected loaded identity, got %#v", identityMsg.identity) + if !model.bootSplash || !cfg.BootSplash || !gotEnabled { + t.Fatalf("expected boot splash preference enabled, model=%v cfg=%v persisted=%v", model.bootSplash, cfg.BootSplash, gotEnabled) } } -func TestStartupCallerIdentityStillLoadsNonSSOIdentity(t *testing.T) { - origLoad := appLoadCallerIdentityFn +func TestSettingsTogglesBootSplashPreference_Error(t *testing.T) { + origSetBootSplashEnabledFn := configSetBootSplashEnabledFn defer func() { - appLoadCallerIdentityFn = origLoad + configSetBootSplashEnabledFn = origSetBootSplashEnabledFn }() - loadCalled := false - appLoadCallerIdentityFn = func(Model) tea.Cmd { - loadCalled = true - return func() tea.Msg { - return callerIdentityMsg{identity: &awsservice.CallerIdentity{Account: "123456789012"}} + configSetBootSplashEnabledFn = func(path string, enabled bool) error { + return errors.New("disk full") + } + + cfg := testConfig() + cfg.BootSplash = false + m := New(cfg, "config.yaml", "dev") + m.screen = screenSettings + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + + if model.screen != screenError { + t.Fatalf("expected error screen after failed persist, got %v", model.screen) + } + if !strings.Contains(model.errMsg, "disk full") { + t.Fatalf("expected error message to surface persist failure, got %q", model.errMsg) + } + // In-memory state must not change when the config write fails. + if model.bootSplash { + t.Fatal("expected model.bootSplash to stay false when persist fails") + } + if cfg.BootSplash { + t.Fatal("expected cfg.BootSplash to stay false when persist fails") + } +} + +func TestGlobalSettingsShortcutOpensSettings(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenContextPicker + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'S'}}) + model := updated.(Model) + if cmd != nil { + t.Fatal("expected settings shortcut to be synchronous") + } + if model.screen != screenSettings { + t.Fatalf("expected settings screen, got %v", model.screen) + } + if model.settingsPrevScreen != screenContextPicker { + t.Fatalf("expected previous screen to be context picker, got %v", model.settingsPrevScreen) + } +} + +func TestSettingsViewShowsBootSplashSetting(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenSettings + + view := stripANSI(m.View()) + for _, want := range []string{"Settings", "Boot splash", "enter/space: toggle"} { + if !strings.Contains(view, want) { + t.Fatalf("expected settings view to contain %q, got %q", want, view) } } +} + +func TestBootupViewCentersVertically(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenBootup + m.width = 80 + m.height = 30 - m := New(&config.Config{ - AuthType: config.AuthTypeCredential, - Profile: "default", - Region: "us-east-1", - }, "", "dev") + view := stripANSI(m.View()) + lines := strings.Split(view, "\n") + firstContent := -1 + for i, line := range lines { + if strings.TrimSpace(line) != "" { + firstContent = i + break + } + } - msg := m.loadStartupCallerIdentity()() - if _, ok := msg.(callerIdentityMsg); !ok { - t.Fatalf("expected callerIdentityMsg, got %T", msg) + if firstContent < 4 { + t.Fatalf("expected bootup content to be vertically centered, first content line %d in %q", firstContent, view) } - if !loadCalled { - t.Fatal("expected non-SSO startup identity loading to continue") + if !strings.Contains(view, "UNIC BIOS") { + t.Fatalf("expected centered bootup view to keep boot text, got %q", view) } } diff --git a/internal/app/help.go b/internal/app/help.go index f9d7342..f6c6b61 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -78,6 +78,12 @@ func (m Model) globalHelpShortcuts() []helpShortcut { m.screen != screenBedrockKeyConfirm { shortcuts = append(shortcuts, helpShortcut{"C", "Open the context picker"}) } + if m.screen != screenSettings && + m.screen != screenSecurityGroupAddRule && m.screen != screenSecurityGroupDeleteConfirm && + m.screen != screenLambdaInvokeInput && m.screen != screenBedrockKeyCreate && + m.screen != screenBedrockKeyConfirm { + shortcuts = append(shortcuts, helpShortcut{"S", "Open settings"}) + } return shortcuts } @@ -669,6 +675,12 @@ func (m Model) currentScreenShortcuts() []helpShortcut { shortcuts = append(shortcuts[:7], append([]helpShortcut{{"esc", "Return to the previous screen"}}, shortcuts[7:]...)...) } return shortcuts + case screenSettings: + return []helpShortcut{ + {"↑/↓, j/k", "Move between settings"}, + {"enter / space", "Toggle the selected setting"}, + {"q / esc", "Go back"}, + } case screenContextAdd: if m.addStep == 0 { return []helpShortcut{ @@ -948,6 +960,8 @@ func (m Model) helpScreenTitle() string { return "Select SSO Account" case screenContextSSORoleList: return "Select SSO Role" + case screenSettings: + return "Settings" case screenLoading: return "Loading" case screenError: diff --git a/internal/app/messages.go b/internal/app/messages.go index 2088880..cf456c6 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -45,8 +45,19 @@ type callerIdentityMsg struct { identity *awsservice.CallerIdentity } +type screenReadyMsg struct{} + +type bootupStartMsg struct{} + +type bootupTickMsg struct{} + type contextsLoadedMsg struct { contexts []config.ContextInfo + // startup marks the initial background load triggered by Init. Startup + // loads must not steal navigation from a screen the user opened in the + // meantime (e.g. Settings); explicit loads (the C shortcut, post-add + // reloads) always surface the context picker. + startup bool } type contextSwitchedMsg struct { diff --git a/internal/app/screen_bootup.go b/internal/app/screen_bootup.go new file mode 100644 index 0000000..79762c0 --- /dev/null +++ b/internal/app/screen_bootup.go @@ -0,0 +1,154 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const bootupFrameCount = 28 + +func (m Model) viewBootup() string { + frame := clampListIndex(m.bootFrame, bootupFrameCount) + logo := bootupLogo(frame) + lines := []string{ + "", + dimStyle.Render(bootupBanner(m.currentVersion)), + "", + logo, + "", + bootupProgress(frame), + } + lines = append(lines, bootupDiagnostics(frame)...) + lines = append(lines, + "", + dimStyle.Render("enter/esc/space: skip q: quit"), + ) + + content := strings.Join(lines, "\n") + if m.width > 0 { + content = lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(content) + } + return m.centerBootupVertically(content) +} + +// bootupBanner builds the BIOS header line, reflecting the running app version +// instead of a hardcoded one. Empty/unknown versions fall back to "dev". +func bootupBanner(version string) string { + v := strings.TrimSpace(version) + if v == "" { + v = "dev" + } + if v != "dev" && !strings.HasPrefix(v, "v") { + v = "v" + v + } + return fmt.Sprintf("UNIC BIOS %s COPYRIGHT 1986-2026 DEVOPS ART FACTORY", v) +} + +func (m Model) centerBootupVertically(content string) string { + if m.height <= 0 { + return content + } + lines := strings.Split(content, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) >= m.height { + return strings.Join(lines, "\n") + } + + topPadding := (m.height - len(lines)) / 2 + centered := make([]string, 0, topPadding+len(lines)) + for i := 0; i < topPadding; i++ { + centered = append(centered, "") + } + centered = append(centered, lines...) + return strings.Join(centered, "\n") +} + +func bootupLogo(frame int) string { + logo := []string{ + "██ ██ ███ ██ ██ ██████", + "██ ██ ████ ██ ██ ██ ", + "██ ██ ██ ████ ██ ██ ", + "██ ██ ██ ███ ██ ██ ", + " █████ ██ ██ ██ ██████", + } + + progress := float64(frame) / float64(bootupFrameCount-1) + if progress < 0 { + progress = 0 + } + if progress > 1 { + progress = 1 + } + + rendered := make([]string, 0, len(logo)) + for i, line := range logo { + rendered = append(rendered, bootupFillLine(line, progress, i)) + } + return strings.Join(rendered, "\n") +} + +func bootupFillLine(line string, progress float64, row int) string { + runes := []rune(line) + width := len(runes) + fillWidth := int(progress*float64(width+6)) - row + if fillWidth < 0 { + fillWidth = 0 + } + if fillWidth > width { + fillWidth = width + } + + var b strings.Builder + for i, r := range runes { + ch := string(r) + switch { + case i < fillWidth: + b.WriteString(titleStyle.Render(ch)) + case i < fillWidth+2 && r != ' ': + b.WriteString(normalStyle.Render(ch)) + default: + b.WriteString(dimStyle.Render(ch)) + } + } + return b.String() +} + +func bootupProgress(frame int) string { + total := 24 + filled := frame * total / (bootupFrameCount - 1) + if filled > total { + filled = total + } + bar := strings.Repeat("#", filled) + strings.Repeat("-", total-filled) + status := "SEEKING AWS SECTOR" + if frame > 16 { + status = "CONTEXT BUS READY" + } + if frame >= bootupFrameCount-1 { + status = "READY" + } + return fmt.Sprintf("%s %s", successStyle.Render("["+bar+"]"), warningStyle.Render(status)) +} + +func bootupDiagnostics(frame int) []string { + steps := []string{ + "memcheck: 640K operational intent", + "context rom: mounted", + "aws bus: listening", + "operator console: online", + } + + lines := make([]string, 0, len(steps)) + for i, step := range steps { + if frame > 7+i*4 { + lines = append(lines, " "+successStyle.Render("OK")+" "+normalStyle.Render(step)) + continue + } + lines = append(lines, " "+dimStyle.Render("--")+" "+dimStyle.Render(step)) + } + return lines +} diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index d03e533..892a059 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -37,7 +37,14 @@ func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { } } m.syncContextTable() - m.screen = screenContextPicker + // Startup loads only populate the context list; screen transitions are + // owned by the Init sequence (screenReadyMsg / the boot splash flow), so + // a late background load never overrides a screen the user navigated to + // in the meantime (e.g. Settings). Explicit loads (C shortcut, post-add + // reloads) surface the picker directly, but never interrupt the splash. + if !msg.startup && m.screen != screenBootup { + m.screen = screenContextPicker + } return m, nil, true case ssoLoginDoneMsg: @@ -80,12 +87,24 @@ func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { } func (m Model) loadContexts() tea.Cmd { + return m.loadContextsCmd(false) +} + +// loadStartupContexts loads contexts during the initial Init sequence. Unlike +// loadContexts, the resulting message is flagged as a startup load so its +// handler won't bounce the user away from a screen they navigated to while the +// load was in flight. +func (m Model) loadStartupContexts() tea.Cmd { + return m.loadContextsCmd(true) +} + +func (m Model) loadContextsCmd(startup bool) tea.Cmd { return func() tea.Msg { contexts, err := config.Contexts(m.configPath) if err != nil || len(contexts) == 0 { - return contextsLoadedMsg{} + return contextsLoadedMsg{startup: startup} } - return contextsLoadedMsg{contexts: contexts} + return contextsLoadedMsg{contexts: contexts, startup: startup} } } @@ -304,13 +323,13 @@ func (m Model) viewContextPicker() string { return b.String() } if compact { - b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: switch • /: filter • a: add • q: quit")) + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: switch • /: filter • a: add • S: settings • q: quit")) return b.String() } if m.cfg.ContextName != "" { - b.WriteString(m.renderHelpBar("↑/↓: navigate • type: filter • /: filter • enter: switch • s: setup • y: copy env • u: unset • a: add • esc: clear/back • q: quit")) + b.WriteString(m.renderHelpBar("↑/↓: navigate • type: filter • /: filter • enter: switch • s: setup • y: copy env • u: unset • a: add • S: settings • esc: clear/back • q: quit")) } else { - b.WriteString(m.renderHelpBar("↑/↓: navigate • type: filter • /: filter • enter: switch • s: setup • y: copy env • u: unset • a: add • q: quit")) + b.WriteString(m.renderHelpBar("↑/↓: navigate • type: filter • /: filter • enter: switch • s: setup • y: copy env • u: unset • a: add • S: settings • q: quit")) } return b.String() } @@ -320,7 +339,7 @@ func shouldStartContextIncrementalFilter(msg tea.KeyMsg) bool { return false } switch msg.String() { - case "/", "q", "s", "y", "u", "a", "j", "k": + case "/", "q", "s", "y", "u", "a", "S", "j", "k": return false } r := msg.Runes[0] diff --git a/internal/app/screen_settings.go b/internal/app/screen_settings.go new file mode 100644 index 0000000..8eaa421 --- /dev/null +++ b/internal/app/screen_settings.go @@ -0,0 +1,95 @@ +package app + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type settingsItem struct { + name string + value string + description string +} + +func (m Model) settingsItems() []settingsItem { + return []settingsItem{ + { + name: "Boot splash", + value: onOff(m.bootSplash), + description: "Show the retro startup splash on every launch. When off, it still appears once after install/update.", + }, + } +} + +func (m Model) updateSettings(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + items := m.settingsItems() + switch msg.String() { + case "q", "esc": + if m.settingsPrevScreen == 0 || m.settingsPrevScreen == screenSettings { + m.screen = screenContextPicker + } else { + m.screen = m.settingsPrevScreen + } + case "up", "k": + m.settingsIdx = previousListIndex(m.settingsIdx, len(items)) + case "down", "j": + m.settingsIdx = nextListIndex(m.settingsIdx, len(items)) + case "enter", " ": + return m.toggleSelectedSetting() + } + return m, nil +} + +func (m Model) toggleSelectedSetting() (tea.Model, tea.Cmd) { + switch m.settingsIdx { + case 0: + if err := m.toggleBootSplash(); err != nil { + m.errMsg = err.Error() + m.screen = screenError + } + } + return m, nil +} + +func (m Model) viewSettings() string { + var b strings.Builder + var panel strings.Builder + items := m.settingsItems() + m.settingsIdx = clampListIndex(m.settingsIdx, len(items)) + + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Settings")) + b.WriteString("\n\n") + + // Width-aware name column so styled (ANSI-wrapped) cells stay aligned. + nameCol := lipgloss.NewStyle().Width(18) + for i, item := range items { + prefix := " " + nameStyle := normalStyle + valueStyle := dimStyle + if i == m.settingsIdx { + prefix = "> " + nameStyle = selectedStyle + valueStyle = selectedStyle + } + nameCell := nameCol.Render(nameStyle.Render(item.name)) + valueCell := valueStyle.Render(item.value) + panel.WriteString(prefix + nameCell + " " + valueCell + "\n") + panel.WriteString(" " + dimStyle.Render(item.description)) + panel.WriteString("\n") + } + + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter/space: toggle • esc/q: back")) + return b.String() +} + +func onOff(enabled bool) string { + if enabled { + return "on" + } + return "off" +} diff --git a/internal/app/service_list.go b/internal/app/service_list.go index 325406c..649d373 100644 --- a/internal/app/service_list.go +++ b/internal/app/service_list.go @@ -10,6 +10,8 @@ import ( ) var configSetFavoriteServicesFn = config.SetFavoriteServices +var configSetBootSplashEnabledFn = config.SetBootSplashEnabled +var configSetBootSplashSeenVersionFn = config.SetBootSplashSeenVersion func (m Model) serviceList() []domain.Service { if m.filteredServices != nil { @@ -84,6 +86,20 @@ func (m *Model) toggleFavoriteService(name domain.AwsService) error { return nil } +func (m *Model) toggleBootSplash() error { + newVal := !m.bootSplash + if strings.TrimSpace(m.configPath) != "" { + if err := configSetBootSplashEnabledFn(m.configPath, newVal); err != nil { + return err + } + } + m.bootSplash = newVal + if m.cfg != nil { + m.cfg.BootSplash = newVal + } + return nil +} + func (m Model) favoriteServiceNames() []string { names := make([]string, 0, len(m.favoriteServices)) for name := range m.favoriteServices { diff --git a/internal/config/config.go b/internal/config/config.go index d7b87bd..8476225 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type fileConfig struct { Current string `yaml:"current"` Defaults fileDefaults `yaml:"defaults"` Favorites fileFavorites `yaml:"favorites,omitempty"` + UI fileUI `yaml:"ui,omitempty"` Contexts []contextEntry `yaml:"contexts"` } @@ -37,6 +38,15 @@ type fileFavorites struct { Services []string `yaml:"services,omitempty"` } +type fileUI struct { + // BootSplash is tri-state: nil means "unset" (use default behavior), + // while a non-nil value records an explicit user choice. This lets an + // explicit `boot_splash: false` survive a save/load round-trip instead of + // being collapsed into the zero value by `omitempty`. + BootSplash *bool `yaml:"boot_splash,omitempty"` + LastBootSplashVersion string `yaml:"last_boot_splash_version,omitempty"` +} + // AuthType represents the authentication method for a context. type AuthType string @@ -76,6 +86,8 @@ type Config struct { SSOAccountID string SSORoleName string FavoriteServices []string + BootSplash bool + BootSplashSeen string } func normalizeAuthType(value string) AuthType { @@ -193,6 +205,8 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { SSOAccountID: ssoAccountID, SSORoleName: ssoRoleName, FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services), + BootSplash: boolValue(fc.UI.BootSplash, false), + BootSplashSeen: fc.UI.LastBootSplashVersion, }, nil } @@ -237,12 +251,22 @@ func LoadNamedContext(configPath, name string) (*Config, error) { SSOAccountID: ctx.SSOAccountID, SSORoleName: ctx.SSORoleName, FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services), + BootSplash: boolValue(fc.UI.BootSplash, false), + BootSplashSeen: fc.UI.LastBootSplashVersion, }, nil } return nil, fmt.Errorf("context %q not found in config", name) } +// boolValue dereferences a tri-state *bool, returning fallback when unset (nil). +func boolValue(v *bool, fallback bool) bool { + if v == nil { + return fallback + } + return *v +} + func normalizeFavoriteServices(services []string) []string { seen := make(map[string]struct{}, len(services)) normalized := make([]string, 0, len(services)) @@ -623,6 +647,56 @@ func SetFavoriteServices(configPath string, services []string) error { return nil } +// SetBootSplashEnabled updates whether the startup splash should run on every launch. +func SetBootSplashEnabled(configPath string, enabled bool) error { + fc, err := readFileConfigOrDefault(configPath) + if err != nil { + return err + } + fc.UI.BootSplash = &enabled + return writeFileConfig(configPath, fc) +} + +// SetBootSplashSeenVersion records the app version that has already shown the one-time splash. +func SetBootSplashSeenVersion(configPath, version string) error { + fc, err := readFileConfigOrDefault(configPath) + if err != nil { + return err + } + fc.UI.LastBootSplashVersion = strings.TrimSpace(version) + return writeFileConfig(configPath, fc) +} + +func readFileConfigOrDefault(configPath string) (*fileConfig, error) { + data, err := os.ReadFile(configPath) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to read config: %w", err) + } + data = []byte(defaultContent()) + } + + var fc fileConfig + if err := yaml.Unmarshal(data, &fc); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", configPath, err) + } + return &fc, nil +} + +func writeFileConfig(configPath string, fc *fileConfig) error { + out, err := yaml.Marshal(fc) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + if err := os.WriteFile(configPath, out, 0644); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + return nil +} + // DefaultPath returns the default config file path following XDG Base Directory spec. func DefaultPath() (string, error) { dir := os.Getenv("XDG_CONFIG_HOME") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1c62e75..32fdd1c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "gopkg.in/yaml.v3" @@ -157,6 +158,96 @@ favorites: } } +func TestLoadReadsBootSplashSettings(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +ui: + boot_splash: true + last_boot_splash_version: 0.1.2 +`) + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if !cfg.BootSplash { + t.Fatal("expected boot splash setting to load") + } + if cfg.BootSplashSeen != "0.1.2" { + t.Fatalf("expected boot splash seen version 0.1.2, got %q", cfg.BootSplashSeen) + } +} + +func TestSetBootSplashEnabledWritesConfig(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +default_region: ap-northeast-2 +`) + if err := SetBootSplashEnabled(path, true); err != nil { + t.Fatal(err) + } + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if !cfg.BootSplash { + t.Fatal("expected boot splash setting to be enabled") + } + if cfg.Region != "ap-northeast-2" { + t.Fatalf("expected existing region to be preserved, got %q", cfg.Region) + } +} + +func TestSetBootSplashSeenVersionWritesConfig(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +default_region: ap-northeast-2 +`) + if err := SetBootSplashSeenVersion(path, "0.1.3"); err != nil { + t.Fatal(err) + } + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if cfg.BootSplashSeen != "0.1.3" { + t.Fatalf("expected boot splash seen version 0.1.3, got %q", cfg.BootSplashSeen) + } + if cfg.Region != "ap-northeast-2" { + t.Fatalf("expected existing region to be preserved, got %q", cfg.Region) + } +} + +func TestSetBootSplashEnabledFalseSurvivesRoundTrip(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +default_region: ap-northeast-2 +`) + // Explicitly disabling the splash must persist as an explicit choice rather + // than collapsing into "unset" (the omitempty tri-state bug). + if err := SetBootSplashEnabled(path, false); err != nil { + t.Fatal(err) + } + + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if cfg.BootSplash { + t.Fatal("expected boot splash to remain disabled after round-trip") + } + if cfg.Region != "ap-northeast-2" { + t.Fatalf("expected existing region to be preserved, got %q", cfg.Region) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "boot_splash: false") { + t.Fatalf("expected explicit boot_splash: false in config, got:\n%s", data) + } +} + func TestCreatesDefaultConfigFileWhenMissing(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "unic", "config.yaml")