Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -146,6 +148,10 @@ favorites:
- Bedrock
- ECS

ui:
boot_splash: false
last_boot_splash_version: 0.1.3

contexts:
- name: dev-sso
order: 10
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
131 changes: 123 additions & 8 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
Expand All @@ -19,7 +20,8 @@ import (
type screen int

const (
screenServiceList screen = iota
screenBootup screen = iota
screenServiceList
screenFeatureList
screenInstanceList
screenEC2InstanceBrowserList
Expand Down Expand Up @@ -104,19 +106,23 @@ const (
screenContextAdd
screenContextSSOAccountList
screenContextSSORoleList
screenSettings
screenLoading
screenError
screenExitNotice
)

// 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

for _, submodel := range m.featureSubmodels() {
if newM, cmd, handled := submodel.HandleKey(&m, msg); handled {
Expand All @@ -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)
}
Expand Down Expand Up @@ -583,6 +694,8 @@ func (m Model) View() string {
}
}
switch m.screen {
case screenBootup:
v = m.viewBootup()
case screenServiceList:
v = m.viewServiceList()
case screenFeatureList:
Expand All @@ -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:
Expand Down
Loading
Loading