hub: ASCII banner, MOTD, ctrl+c→main-menu everywhere, Shell option

UX pass on the single-login hub:

- ctrl+c now always returns to the main menu from any in-hub plugin/game
  instead of being forwarded. Fixes the snake game being un-exitable (it never
  handled ctrl+c) and makes "back to menu" uniform across every screen.
- profullstack.com ASCII banner rendered atop the hub menu and the join@
  onboarding flow.
- MOTD box under the title (guest vs member tailored; AGENTBBS_MOTD overrides
  the body).
- New "Shell" hub entry drops straight into `bash -l` in the member's pod; the
  existing "Pod" entry now attaches to the pod's main session.
- Polish: highlighted selection + ❯ cursor, brand colors, clearer footer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 15:29:18 +00:00
parent 5eb1e96480
commit 49db5e103d
2 changed files with 72 additions and 16 deletions

View file

@ -12,6 +12,7 @@ package hub
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@ -23,9 +24,16 @@ import (
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
selStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0"))
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
frameStyle = lipgloss.NewStyle().Padding(1, 2)
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#38bdf8"))
motdStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#4ade80")).
Foreground(lipgloss.Color("252")).
Padding(0, 1)
frameStyle = lipgloss.NewStyle().Padding(1, 2)
)
// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC
@ -52,6 +60,8 @@ type Model struct {
ctx plugin.Context
plugins []plugin.Plugin
apps []SessionApp
banner string // ASCII brand banner shown above the menu (may be empty)
motd string // message-of-the-day shown in a box under the title (may be empty)
cursor int
active tea.Model
@ -61,9 +71,10 @@ type Model struct {
}
// New builds a hub for one session. apps are terminal-takeover features listed
// after the in-hub plugins (may be nil).
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin, apps []SessionApp) Model {
return Model{user: user, ctx: ctx, plugins: plugins, apps: apps}
// after the in-hub plugins (may be nil). banner is an ASCII brand banner and
// motd a short welcome message; either may be empty.
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin, apps []SessionApp, banner, motd string) Model {
return Model{user: user, ctx: ctx, plugins: plugins, apps: apps, banner: banner, motd: motd}
}
func (m Model) Init() tea.Cmd { return nil }
@ -85,8 +96,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// A plugin owns the session until it emits ExitMsg (PRD §4.3).
// A plugin owns the session until it emits ExitMsg (PRD §4.3) — except
// ctrl+c, which the hub always reclaims so EVERY menu/game has a guaranteed
// way back to the main menu (some plugins, e.g. snake, don't handle it).
if m.active != nil {
if k, ok := msg.(tea.KeyMsg); ok && k.String() == "ctrl+c" {
m.active = nil
m.note = "← back to main menu"
return m, nil
}
if _, ok := msg.(plugin.ExitMsg); ok {
m.active = nil
return m, nil
@ -147,15 +165,23 @@ func (m Model) View() string {
if m.active != nil {
return m.active.View()
}
var b strings.Builder
if m.banner != "" {
b.WriteString(bannerStyle.Render(m.banner) + "\n\n")
}
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n"
b.WriteString(titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n")
if m.motd != "" {
b.WriteString("\n" + motdStyle.Render(m.motd) + "\n")
}
b.WriteString("\n")
row := 0
for _, p := range m.plugins {
label := p.Title()
if p.RequiresAuth() && m.user.Kind == auth.Guest {
label += lockStyle.Render(" [members]")
}
s += m.renderRow(row, label, p.Description())
b.WriteString(m.renderRow(row, label, p.Description()))
row++
}
for _, app := range m.apps {
@ -163,21 +189,23 @@ func (m Model) View() string {
if app.Locked != "" {
label += lockStyle.Render(" [locked]")
}
s += m.renderRow(row, label, app.Description)
b.WriteString(m.renderRow(row, label, app.Description))
row++
}
s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit")
b.WriteString("\n" + dimStyle.Render("↑/↓ move · enter select · ctrl+c back · q quit"))
if m.note != "" {
s += "\n" + lockStyle.Render(m.note)
b.WriteString("\n" + lockStyle.Render(m.note))
}
return frameStyle.Render(s)
return frameStyle.Render(b.String())
}
// renderRow renders one menu line with the cursor and dimmed description.
// renderRow renders one menu line with the cursor and dimmed description. The
// selected row's cursor and label are highlighted.
func (m Model) renderRow(i int, label, desc string) string {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render("> ")
cur = cursorStyle.Render(" ")
label = selStyle.Render(label)
}
return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc))
}