diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index ed21424..7e166a4 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -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) diff --git a/internal/hub/hub.go b/internal/hub/hub.go index c06c5bc..9f42402 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -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)) }