mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
5eb1e96480
commit
49db5e103d
2 changed files with 72 additions and 16 deletions
|
|
@ -42,6 +42,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/charmbracelet/ssh"
|
"github.com/charmbracelet/ssh"
|
||||||
"github.com/charmbracelet/wish"
|
"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.
|
// teaHandler builds the hub model for guests, members, and agents.
|
||||||
func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
fp := auth.Fingerprint(s.PublicKey())
|
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.
|
// URL works the moment a member first signs in.
|
||||||
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
|
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
|
// 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:
|
case a.pods == nil:
|
||||||
podLock = "pods are temporarily unavailable on this host"
|
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{
|
apps = append(apps, hub.SessionApp{
|
||||||
Title: "Pod",
|
Title: "Pod",
|
||||||
Description: "your own Linux pod — a full shell",
|
Description: "your own Linux pod — attach to its main session",
|
||||||
Locked: podLock,
|
Locked: podLock,
|
||||||
Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }},
|
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)
|
_ = s.Exit(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
wish.Println(s, "\n"+bannerStyle.Render(bbsBanner))
|
||||||
in := bufio.NewReader(s)
|
in := bufio.NewReader(s)
|
||||||
|
|
||||||
u, found, err := a.st.UserByFingerprint(fp)
|
u, found, err := a.st.UserByFingerprint(fp)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
@ -23,8 +24,15 @@ import (
|
||||||
var (
|
var (
|
||||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
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"))
|
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||||
|
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)
|
frameStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -52,6 +60,8 @@ type Model struct {
|
||||||
ctx plugin.Context
|
ctx plugin.Context
|
||||||
plugins []plugin.Plugin
|
plugins []plugin.Plugin
|
||||||
apps []SessionApp
|
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
|
cursor int
|
||||||
active tea.Model
|
active tea.Model
|
||||||
|
|
@ -61,9 +71,10 @@ type Model struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a hub for one session. apps are terminal-takeover features listed
|
// New builds a hub for one session. apps are terminal-takeover features listed
|
||||||
// after the in-hub plugins (may be nil).
|
// after the in-hub plugins (may be nil). banner is an ASCII brand banner and
|
||||||
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin, apps []SessionApp) Model {
|
// motd a short welcome message; either may be empty.
|
||||||
return Model{user: user, ctx: ctx, plugins: plugins, apps: apps}
|
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 }
|
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
|
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 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 {
|
if _, ok := msg.(plugin.ExitMsg); ok {
|
||||||
m.active = nil
|
m.active = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -147,15 +165,23 @@ func (m Model) View() string {
|
||||||
if m.active != nil {
|
if m.active != nil {
|
||||||
return m.active.View()
|
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)
|
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
|
row := 0
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
label := p.Title()
|
label := p.Title()
|
||||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||||
label += lockStyle.Render(" [members]")
|
label += lockStyle.Render(" [members]")
|
||||||
}
|
}
|
||||||
s += m.renderRow(row, label, p.Description())
|
b.WriteString(m.renderRow(row, label, p.Description()))
|
||||||
row++
|
row++
|
||||||
}
|
}
|
||||||
for _, app := range m.apps {
|
for _, app := range m.apps {
|
||||||
|
|
@ -163,21 +189,23 @@ func (m Model) View() string {
|
||||||
if app.Locked != "" {
|
if app.Locked != "" {
|
||||||
label += lockStyle.Render(" [locked]")
|
label += lockStyle.Render(" [locked]")
|
||||||
}
|
}
|
||||||
s += m.renderRow(row, label, app.Description)
|
b.WriteString(m.renderRow(row, label, app.Description))
|
||||||
row++
|
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 != "" {
|
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 {
|
func (m Model) renderRow(i int, label, desc string) string {
|
||||||
cur := " "
|
cur := " "
|
||||||
if i == m.cursor {
|
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))
|
return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue