From b37caa5a255f36a6b74c3ca0ec2a9c0d801e50c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:17:22 +0200 Subject: [PATCH] feat: an account pane on Azure account rows: fields and groups to move through and copy Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/azure.md | 13 ++-- docs/keys.md | 2 +- internal/provider/azure/account.go | 70 +++++++++++++++++++ internal/provider/azure/account_test.go | 47 +++++++++++++ internal/provider/azure/azure.go | 12 ++-- internal/provider/provider.go | 7 ++ internal/ui/app/app.go | 32 +++++++-- internal/ui/app/app_test.go | 38 +++++++++++ internal/ui/app/help.go | 5 ++ internal/ui/app/layout.go | 8 ++- internal/ui/app/source_detail.go | 90 +++++++++++++++++++++++++ internal/ui/panes/detail.go | 11 --- internal/ui/panes/source_detail.go | 88 ++++++++++++++++++++++++ 13 files changed, 396 insertions(+), 27 deletions(-) create mode 100644 internal/ui/app/source_detail.go create mode 100644 internal/ui/panes/source_detail.go diff --git a/docs/azure.md b/docs/azure.md index 75bc2b4..f82dfa7 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -13,12 +13,15 @@ is listed under each, since their permissions can differ. A service principal is used instead when `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and a secret or certificate are set in the environment. -On an account's group row, the Details pane shows who it signs in as: name, +On an account's group row, the fourth pane shows who it signs in as: name, user, tenant, object ID, whether the session used MFA, directory roles -(built-in ones by name) and its subscriptions. It's read from az's account -list and the token lazysecrets already has, so nothing is fetched for it. -`y` there copies the object ID, the one access policies and role -assignments ask for. +(built-in ones by name) and its subscriptions, read from az's account list +and the token lazysecrets already has. Below them are the groups the +account is in, nested ones too: those come from Microsoft Graph, once a +session, the first time the pane shows them. `y` on the row copies the +object ID, the one access policies and role assignments ask for; `tab` +goes into the pane, where `j`/`k` pick any field or group and `y` copies +it. If an account's az login has expired, the status line says so once, and the Details pane on that account's rows says it until you sign in: press `L` diff --git a/docs/keys.md b/docs/keys.md index 5bf842f..2058e77 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -48,7 +48,7 @@ match case exactly. | Key | Action | |---|---| | `space` | reveal / hide the selected value (public material for keys and certificates) | -| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID | +| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field or group | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `o` | open the selected item in the browser (over SSH: copy the link) | diff --git a/internal/provider/azure/account.go b/internal/provider/azure/account.go index 7371203..a995453 100644 --- a/internal/provider/azure/account.go +++ b/internal/provider/azure/account.go @@ -1,10 +1,15 @@ package azure import ( + "context" + "encoding/json" "fmt" + "net/http" "slices" "strings" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -158,3 +163,68 @@ func directoryRoles(wids any) string { slices.Sort(names) return strings.Join(names, ", ") } + +// graphGroups lists the signed-in user's groups, nested ones too, from +// Microsoft Graph with az's token for it. +const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999" + +var _ provider.AccountGroups = (*Provider)(nil) + +// Groups lists the groups the account is a member of, by name. +func (p *Provider) Groups(ctx context.Context) ([]string, error) { + if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") { + return nil, provider.ErrUnsupported + } + tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}}) + if err != nil { + return nil, err + } + var names []string + for next := graphGroups; next != ""; { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok.Token) + resp, err := p.http().Do(req) + if err != nil { + return nil, err + } + var page struct { + Value []struct { + Type string `json:"@odata.type"` + Name string `json:"displayName"` + } `json:"value"` + Next string `json:"@odata.nextLink"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + err = json.NewDecoder(resp.Body).Decode(&page) + resp.Body.Close() + switch { + case err != nil: + return nil, fmt.Errorf("Microsoft Graph: %w", err) + case page.Error != nil: + return nil, fmt.Errorf("Microsoft Graph: %s", page.Error.Message) + case resp.StatusCode != http.StatusOK: + return nil, fmt.Errorf("Microsoft Graph: %s", resp.Status) + } + for _, v := range page.Value { + if v.Type == "#microsoft.graph.group" && v.Name != "" { + names = append(names, v.Name) + } + } + next = page.Next + } + slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }) + return names, nil +} + +// http is the client for Microsoft Graph; tests replace it. +func (p *Provider) http() *http.Client { + if p.graphClient != nil { + return p.graphClient + } + return http.DefaultClient +} diff --git a/internal/provider/azure/account_test.go b/internal/provider/azure/account_test.go index c82e32f..7027595 100644 --- a/internal/provider/azure/account_test.go +++ b/internal/provider/azure/account_test.go @@ -1,7 +1,17 @@ package azure import ( + "context" + "errors" + "io" + "net/http" + "slices" + "strings" "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -65,3 +75,40 @@ func TestSignInMethods(t *testing.T) { } } } + +type staticToken struct{} + +func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +type graphPages map[string]string // by URL + +func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) { + body, ok := g[r.URL.String()] + if !ok || r.Header.Get("Authorization") != "Bearer tok" { + return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil + } + return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func TestGroups(t *testing.T) { + next := "https://graph.microsoft.com/v1.0/next" + p := &Provider{ + cred: staticToken{}, + account: &accountDetails{account: account{user: "alice@contoso.com"}}, + graphClient: &http.Client{Transport: graphPages{ + graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"}, + {"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`, + next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`, + }}, + } + got, err := p.Groups(context.Background()) + if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) { + t.Errorf("groups %q, %v", got, err) + } + p.account.cloud = "AzureChinaCloud" + if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) { + t.Errorf("other cloud: %v", err) + } +} diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go index 1d50999..b7b0629 100644 --- a/internal/provider/azure/azure.go +++ b/internal/provider/azure/azure.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "errors" "fmt" + "net/http" "regexp" "slices" "strings" @@ -36,11 +37,12 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p type Provider struct { id, label string // "" for the single-account defaults - cred azcore.TokenCredential - opts options - account *accountDetails // nil for a service principal from AZURE_* - subs *armsubscriptions.Client - graph *armresourcegraph.Client + cred azcore.TokenCredential + opts options + account *accountDetails // nil for a service principal from AZURE_* + graphClient *http.Client // for Groups; nil for the default + subs *armsubscriptions.Client + graph *armresourcegraph.Client mu sync.Mutex vaults map[string]*vaultClients // by vault URL diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0857baf..2cce540 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -198,3 +198,10 @@ type Account struct { // Copy is what y copies on the provider's row, named CopyLabel. Copy, CopyLabel string } + +// AccountGroups is implemented by providers that can list the groups +// their account is a member of, directly or through other groups. It +// goes over the network. +type AccountGroups interface { + Groups(ctx context.Context) ([]string, error) +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 2e1eee2..f12cc31 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -163,6 +163,10 @@ type Model struct { clip Clipboard clipClear time.Duration openURL func(string) error + // srcDetail is the fourth pane on a provider's row, with the groups + // of each provider's account as they load (source_detail.go). + srcDetail panes.SourceDetail + groups map[string]groupsState getenv func(string) string nvimOpen func(server, path string, line int) error // edit.go now func() time.Time @@ -356,6 +360,7 @@ func New(providers []provider.Provider, opts Options) Model { getenv: os.Getenv, nvimOpen: nvimOpen, cache: newCache(), + groups: map[string]groupsState{}, } if m.clip == nil { m.clip = clipboard.New() @@ -563,6 +568,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case persistedMsg: m.handlePersisted(msg) + case groupsMsg: + m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err} + case crawlMsg: cmds = append(cmds, m.handleCrawl(msg)) @@ -723,8 +731,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.askHandlerSetup() m.keys.Login.SetEnabled(m.loginFor() != nil) m.detail.SetMessage(m.selectedProblem()) - acct, _ := m.selectedAccount() - m.detail.SetAccount(acct.Fields) + cmds = append(cmds, m.syncSourceDetail()) if m.anyLoading() && !m.spinning { m.spinning = true cmds = append(cmds, m.spin.Tick) @@ -791,8 +798,14 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) { m.jump(false) case key.Matches(msg, k.NextPane): m.focus = (m.focus + 1) % paneCount + if m.focus == paneContainers && m.srcDetail.Shown() { + m.focus = paneDetail // an account's row: nothing between + } case key.Matches(msg, k.PrevPane): m.focus = (m.focus + paneCount - 1) % paneCount + if m.focus == paneSecrets && m.srcDetail.Shown() { + m.focus = paneSources + } case key.Matches(msg, k.Pane1): m.focus = paneSources case key.Matches(msg, k.Pane2): @@ -928,7 +941,11 @@ func (m *Model) move(delta int) { case paneSecrets: m.secrets.Move(delta) case paneDetail: - m.detail.Scroll(delta) + if m.srcDetail.Shown() { + m.srcDetail.Move(delta) + } else { + m.detail.Scroll(delta) + } } } @@ -948,6 +965,10 @@ func (m *Model) jump(top bool) { m.secrets.Top() case m.focus == paneSecrets: m.secrets.Bottom() + case m.focus == paneDetail && m.srcDetail.Shown() && top: + m.srcDetail.Top() + case m.focus == paneDetail && m.srcDetail.Shown(): + m.srcDetail.Bottom() case m.focus == paneDetail && top: m.detail.ScrollTop() case m.focus == paneDetail: @@ -1458,6 +1479,9 @@ func (m *Model) copyValue() tea.Cmd { if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources { return m.copyText(a.CopyLabel, a.Copy, false) } + if f, ok := m.srcDetail.Selected(); ok && m.focus == paneDetail && m.srcDetail.Shown() { + return m.copyText(fieldName(f), f.Value, false) + } return nil } if v, ok := m.detail.Value(); ok { @@ -1583,7 +1607,7 @@ func (m Model) footerView() string { } else if l != nil && l.Filtering() { help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) } else { - help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}.ShortHelp(), 2) + help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0, account: m.srcDetail.Shown()}.ShortHelp(), 2) } var lines []string if !m.clipDeadline.IsZero() { diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index d952d6b..ef688fd 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1099,3 +1099,41 @@ func TestAccountInDetails(t *testing.T) { t.Errorf("still shown on a subscription:\n%s", d.screen()) } } + +// grouped is an account in two groups. +type grouped struct{ described } + +func (grouped) Groups(context.Context) ([]string, error) { + return []string{"Platform team", "Vault readers"}, nil +} + +func TestAccountPane(t *testing.T) { + t.Parallel() + d := newDriver(t, grouped{described{fake.Azure(0)}}) + d.keys("g", "tab") // the account's row, then straight to its pane + if d.m.focus != paneDetail { + t.Fatalf("focus %v, want the fourth pane", d.m.focus) + } + scr := d.screen() + for _, want := range []string{"Account", "Alice Example", "groups 2", "Platform team", "Vault readers"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + bar := lastLine(scr) + if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { + t.Errorf("help bar: %q", bar) + } + d.keys("j", "y") + if got := d.clip().content; got != "oid-123" { + t.Errorf("y on the object ID copied %q", got) + } + d.keys("G", "y") + if got := d.clip().content; got != "Vault readers" { + t.Errorf("y on a group copied %q", got) + } + d.keys("shift+tab") + if d.m.focus != paneSources { + t.Errorf("back to %v, want Sources", d.m.focus) + } +} diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go index 872443f..2fc9efb 100644 --- a/internal/ui/app/help.go +++ b/internal/ui/app/help.go @@ -36,6 +36,7 @@ type contextHelp struct { k keys.Map focus pane commands bool // custom commands apply here + account bool // the fourth pane describes an account (source_detail.go) } func (c contextHelp) ShortHelp() []key.Binding { @@ -44,6 +45,10 @@ func (c contextHelp) ShortHelp() []key.Binding { if k.Login.Enabled() { b = append(b, k.Login) } + if c.focus == paneDetail && c.account { + copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy")) + return append(b, k.Up, k.Down, copyField, k.Help, k.Quit) + } switch c.focus { case paneSources: b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter) diff --git a/internal/ui/app/layout.go b/internal/ui/app/layout.go index 94e628a..c1a3990 100644 --- a/internal/ui/app/layout.go +++ b/internal/ui/app/layout.go @@ -166,6 +166,7 @@ func (m *Model) layout() { m.containers.SetSize(leftW, h[1]) m.secrets.SetSize(leftW, h[2]) m.detail.SetSize(m.w-leftW, avail) + m.srcDetail.SetSize(m.w-leftW, avail) } // active reports whether p is drawn focused: it has focus and the terminal @@ -179,7 +180,12 @@ func (m Model) panesView() string { func() string { return m.sources.View(m.st, m.active(paneSources), spin) }, func() string { return m.containers.View(m.st, m.active(paneContainers), spin) }, func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) }, - func() string { return m.detail.View(m.st, m.active(paneDetail), spin) }, + func() string { + if m.srcDetail.Shown() { + return m.srcDetail.View(m.st, m.active(paneDetail)) + } + return m.detail.View(m.st, m.active(paneDetail), spin) + }, } if m.zoom == zoomFull { return views[m.focus]() diff --git a/internal/ui/app/source_detail.go b/internal/ui/app/source_detail.go new file mode 100644 index 0000000..93aa27d --- /dev/null +++ b/internal/ui/app/source_detail.go @@ -0,0 +1,90 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// On a provider's row, the fourth pane describes its account: the fields +// the provider knows locally, then the groups the account is in, loaded +// once a session when first shown. + +type groupsState struct { + names []string + err error + loading bool +} + +type groupsMsg struct { + provider string + names []string + err error +} + +// syncSourceDetail fills the pane for the selected row, loading the +// account's groups the first time. +func (m *Model) syncSourceDetail() tea.Cmd { + acct, ok := m.selectedAccount() + if !ok { + m.srcDetail.Set("", nil) + return nil + } + r, _ := m.sources.Selected() + var fields []panes.Field + for _, f := range acct.Fields { + fields = append(fields, panes.Field{Label: f.Label, Value: f.Value}) + } + var cmd tea.Cmd + if g, ok := m.byID[r.provider].(provider.AccountGroups); ok { + st, seen := m.groups[r.provider] + if !seen { + st = groupsState{loading: true} + m.groups[r.provider] = st + id, timeout := r.provider, m.timeout + cmd = func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + names, err := g.Groups(ctx) + return groupsMsg{provider: id, names: names, err: err} + } + } + fields = append(fields, groupFields(st)...) + } + m.srcDetail.Set("Account", fields) + return cmd +} + +// fieldName names a copied field: its label, or for a group, the group. +func fieldName(f panes.Field) string { + if f.Label == "" || strings.HasPrefix(f.Label, "groups") { + return "group " + f.Value + } + return f.Label +} + +// groupFields are the groups' rows: labelled on the first. +func groupFields(st groupsState) []panes.Field { + switch { + case st.loading: + return []panes.Field{{Label: "groups", Value: "loading…", Info: true}} + case errors.Is(st.err, provider.ErrUnsupported): + return nil + case st.err != nil: + return []panes.Field{{Label: "groups", Value: "couldn't list them: " + st.err.Error(), Info: true}} + case len(st.names) == 0: + return []panes.Field{{Label: "groups", Value: "none", Info: true}} + } + out := make([]panes.Field, len(st.names)) + for i, n := range st.names { + out[i] = panes.Field{Value: n} + } + out[0].Label = fmt.Sprintf("groups %d", len(st.names)) + return out +} diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index dbdab8c..f1f2e15 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -47,7 +47,6 @@ type Detail struct { versions []provider.Object // newest first; nil while unknown note string // the user's note on what's shown message string // shown instead of the hint when nothing is selected: an error - account []provider.Attr // shown instead of the hint on a provider's row: who it signs in as offset int w, h int @@ -77,10 +76,6 @@ func (d *Detail) Clear() { // selected, such as why an account has nothing to show; "" for the hint. func (d *Detail) SetMessage(s string) { d.message = s } -// SetAccount sets the account shown on a provider's row when nothing is -// selected; nil for the hint. -func (d *Detail) SetAccount(fields []provider.Attr) { d.account = fields } - // SetDescribing marks extra metadata as being fetched. func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil } @@ -196,12 +191,6 @@ func (d Detail) lines(st styles.Styles) []string { muted := st.Muted.Render if d.container == nil { - if d.message == "" && len(d.account) > 0 { - for _, a := range d.account { - field(a.Label, a.Value) - } - return out - } if d.message != "" { for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") { out = append(out, " "+st.Error.Render(l)) diff --git a/internal/ui/panes/source_detail.go b/internal/ui/panes/source_detail.go new file mode 100644 index 0000000..f18ed0d --- /dev/null +++ b/internal/ui/panes/source_detail.go @@ -0,0 +1,88 @@ +package panes + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// SourceDetail is the fourth pane on a provider's row: what's known about +// its account, one field per row, to move through and copy from. +type SourceDetail struct { + title string + fields []Field + sel int + w, h int +} + +// Field is a row: a value under a label. Info rows (loading, errors) +// can't be copied. +type Field struct { + Label, Value string + Info bool +} + +// Set shows fields under title, keeping the cursor where it was for the +// same title. +func (s *SourceDetail) Set(title string, fields []Field) { + if title != s.title { + s.sel = 0 + } + s.title, s.fields = title, fields + s.sel = max(0, min(s.sel, len(fields)-1)) +} + +// Shown reports whether there's anything to show. +func (s SourceDetail) Shown() bool { return len(s.fields) > 0 } + +func (s *SourceDetail) SetSize(w, h int) { s.w, s.h = w, h } + +func (s *SourceDetail) Move(delta int) { s.sel = max(0, min(s.sel+delta, len(s.fields)-1)) } +func (s *SourceDetail) Top() { s.sel = 0 } +func (s *SourceDetail) Bottom() { s.sel = max(0, len(s.fields)-1) } + +// Selected is the field under the cursor, unless it's an info row. +func (s SourceDetail) Selected() (Field, bool) { + if s.sel >= len(s.fields) || s.fields[s.sel].Info { + return Field{}, false + } + return s.fields[s.sel], true +} + +func (s SourceDetail) View(st styles.Styles, focused bool) string { + inner := max(1, s.w-2) + rows := max(0, s.h-2) + offset := 0 + if s.sel >= rows { // keep the cursor in view + offset = s.sel - rows + 1 + } + var body []string + for i, f := range s.fields[min(offset, len(s.fields)):] { + if i >= rows { + break + } + value := f.Value + if f.Info { + value = st.Muted.Render(value) + } + line := " " + st.Label.Render(fmt.Sprintf("%-10s", f.Label)) + " " + value + line = ansi.Truncate(line, inner, "…") + if i+offset == s.sel { + pad := strings.Repeat(" ", max(0, inner-ansi.StringWidth(line))) + if focused { + line = st.Selected.Render(ansi.Strip(line) + pad) + } else { + line = st.SelectedBlurred.Render(ansi.Strip(line) + pad) + } + } + body = append(body, line) + } + footer := "" + if len(s.fields) > rows { + footer = fmt.Sprintf("%d of %d", s.sel+1, len(s.fields)) + } + return Box(st, TitleStyle(st, focused).Render(s.title), footer, body, s.w, s.h, focused) +}