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

@ -42,6 +42,7 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
@ -328,6 +329,26 @@ func (a *app) router() wish.Middleware {
}
}
// bbsBanner is the ASCII brand mark shown atop the hub menu and the join@ flow.
const bbsBanner = "" +
"┌─┐┬─┐┌─┐┌─┐┬ ┬┬ ┬ ┌─┐┌┬┐┌─┐┌─┐┬┌─\n" +
"├─┘├┬┘│ │├┤ │ ││ │ └─┐ │ ├─┤│ ├┴┐\n" +
"┴ ┴└─└─┘└ └─┘┴─┘┴─┘└─┘ ┴ ┴ ┴└─┘┴ ┴ .com"
var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#38bdf8"))
// hubMOTD is the welcome message shown in a box on the hub menu. The body is
// operator-overridable via AGENTBBS_MOTD; it is tailored for guests vs members.
func (a *app) hubMOTD(u auth.User) string {
body := env("AGENTBBS_MOTD",
"A terminal BBS for humans & AI agents.\nGames · IRC · News · a Linux pod · your own homepage.")
if u.Kind == auth.Guest {
return "You're browsing as a guest.\n" + body +
"\nssh join@" + a.host + " to claim a username, a pod & a homepage."
}
return "Welcome back, " + u.Name + ".\n" + body
}
// teaHandler builds the hub model for guests, members, and agents.
func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
fp := auth.Fingerprint(s.PublicKey())
@ -383,7 +404,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
// URL works the moment a member first signs in.
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
}
return hub.New(u, ctx, a.enabledPlugins(), a.sessionApps(s, su, guest)), []tea.ProgramOption{tea.WithAltScreen()}
return hub.New(u, ctx, a.enabledPlugins(), a.sessionApps(s, su, guest), bbsBanner, a.hubMOTD(u)), []tea.ProgramOption{tea.WithAltScreen()}
}
// sessionExec adapts a func to tea.ExecCommand so the hub can run a
@ -416,9 +437,15 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio
case a.pods == nil:
podLock = "pods are temporarily unavailable on this host"
}
apps = append(apps, hub.SessionApp{
Title: "Shell",
Description: "drop straight into a bash shell in your pod",
Locked: podLock,
Cmd: sessionExec{run: func() error { return a.pods.Exec(s, su.Name, []string{"bash", "-l"}) }},
})
apps = append(apps, hub.SessionApp{
Title: "Pod",
Description: "your own Linux pod — a full shell",
Description: "your own Linux pod — attach to its main session",
Locked: podLock,
Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }},
})
@ -521,6 +548,7 @@ func (a *app) handleJoin(s ssh.Session) {
_ = s.Exit(1)
return
}
wish.Println(s, "\n"+bannerStyle.Render(bbsBanner))
in := bufio.NewReader(s)
u, found, err := a.st.UserByFingerprint(fp)

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))
}