Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
// ssh <name>@host (from another account) prints a finger card for that member
// ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only;
// sysop@/root@ are aliases)
// ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated,
// agent-vs-agent (also on wss://host/play). See docs/agentgames.md
//
Expand Down
17 changes: 9 additions & 8 deletions internal/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
)

// Live is one connected SSH session, as seen by the registry.
Expand Down Expand Up @@ -70,13 +71,13 @@ var menuItems = []struct {
}

var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
headStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
frameStyle = lipgloss.NewStyle().Padding(1, 2)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
dimStyle = ui.Dim
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
warnStyle = ui.Danger
okStyle = lipgloss.NewStyle().Foreground(ui.Green)
headStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
frameStyle = ui.Frame
)

// Model is the admin console.
Expand Down Expand Up @@ -306,7 +307,7 @@ func (m Model) View() string {
}

header := titleStyle.Render("AgentBBS admin") + dimStyle.Render(" · "+m.admin.Name)
out := header + "\n\n" + body + "\n" + dimStyle.Render(help)
out := header + "\n\n" + body + "\n" + ui.KeyBar(help)
if m.note != "" {
out += "\n" + m.note
}
Expand Down
5 changes: 3 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ var DomainNames = map[string]bool{"domain": true, "domains": true}

// AdminNames are usernames that route to the privileged admin console (PRD §6).
// The route only opens for accounts whose name is in the operator allowlist
// (see IsAdmin); the name itself confers nothing.
var AdminNames = map[string]bool{"admin": true, "sysop": true}
// (see IsAdmin); the name itself confers nothing — so "root" is just a familiar
// alias here, not a backdoor.
var AdminNames = map[string]bool{"admin": true, "sysop": true, "root": true}

// TorURLNames route to the one-shot "fetch a URL over Tor" command (premium).
var TorURLNames = map[string]bool{"tor-url": true}
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package auth
import "testing"

func TestIsAdminName(t *testing.T) {
for _, name := range []string{"admin", "ADMIN", "sysop"} {
for _, name := range []string{"admin", "ADMIN", "sysop", "root"} {
if !IsAdminName(name) {
t.Errorf("IsAdminName(%q) = false, want true", name)
}
Expand Down
64 changes: 25 additions & 39 deletions internal/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,12 @@ import (

"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/ui"
)

var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
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"))
theme = ui.New(ui.Green)
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a"))
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
Expand Down Expand Up @@ -170,42 +161,37 @@ func (m Model) View() string {
b.WriteString(bannerStyle.Render(m.banner) + "\n\n")
}
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
b.WriteString(titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n")
b.WriteString(theme.Title("AgentBBS") + ui.Dim.Render(" · "+who) + "\n")
if m.motd != "" {
b.WriteString("\n" + motdStyle.Render(m.motd) + "\n")
b.WriteString("\n" + theme.Card("", 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]")
if len(m.plugins) > 0 {
b.WriteString(theme.Section("Features") + "\n")
for _, p := range m.plugins {
badge := ""
if p.RequiresAuth() && m.user.Kind == auth.Guest {
badge = ui.Badge(ui.BadgeMuted, "members")
}
b.WriteString(theme.MenuItem(row == m.cursor, p.Title(), badge, p.Description()))
row++
}
b.WriteString(m.renderRow(row, label, p.Description()))
row++
}
for _, app := range m.apps {
label := app.Title
if app.Locked != "" {
label += lockStyle.Render(" [locked]")
if len(m.apps) > 0 {
b.WriteString("\n" + theme.Section("Sessions") + "\n")
for _, app := range m.apps {
badge := ""
if app.Locked != "" {
badge = ui.Badge(ui.BadgeGold, "locked")
}
b.WriteString(theme.MenuItem(row == m.cursor, app.Title, badge, app.Description))
row++
}
b.WriteString(m.renderRow(row, label, app.Description))
row++
}
b.WriteString("\n" + dimStyle.Render("↑/↓ move · enter select · ctrl+c back · q quit"))
b.WriteString("\n" + ui.KeyBar("↑/↓ move · enter select · ctrl+c back · q quit"))
if m.note != "" {
b.WriteString("\n" + lockStyle.Render(m.note))
}
return frameStyle.Render(b.String())
}

// 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("❯ ")
label = selStyle.Render(label)
b.WriteString("\n" + ui.Danger.Render(m.note))
}
return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc))
return ui.Frame.Render(b.String())
}
15 changes: 8 additions & 7 deletions internal/news/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
"github.com/dustin/go-nntp"

"github.com/profullstack/agentbbs/internal/ui"
)

var (
nTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc"))
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(lipgloss.Color("#38bdf8"))
nMeta = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
nFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
nErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
nHint = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
theme = ui.New(ui.Purple)
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(ui.Cyan)
nMeta = ui.Dim
nFrom = lipgloss.NewStyle().Foreground(ui.Green)
nErr = ui.Danger
)

// RunReader connects the member to the loopback NNTP server and drives the
Expand Down Expand Up @@ -311,7 +312,7 @@ func (m *model) frame(header, body, hint string) string {
status = "\n" + nMeta.Render(m.status)
}
return lipgloss.NewStyle().Padding(0, 1).Render(
nTitle.Render(header) + "\n\n" + body + status + "\n\n" + nHint.Render(hint))
theme.Title(header) + "\n\n" + body + status + "\n\n" + ui.KeyBar(hint))
}

func (m *model) viewGroups() string {
Expand Down
159 changes: 159 additions & 0 deletions internal/ui/theme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Package ui is the shared TUI theme for AgentBBS: one palette and a small set
// of structural widgets (cards, menu rows, status badges, key bars) so every
// screen — the hub and each plugin — looks like part of the same product.
//
// Screens keep their own accent color for identity (the hub is green, the
// arcade amber, the newsreader purple) by constructing a Theme with that
// accent; the layout primitives are shared.
package ui

import (
"strings"

"github.com/charmbracelet/lipgloss"
)

// Palette — the only colors any screen should reach for.
const (
Green = lipgloss.Color("#4ade80")
Cyan = lipgloss.Color("#38bdf8")
Blue = lipgloss.Color("#60a5fa")
Gold = lipgloss.Color("#fbbf24")
Purple = lipgloss.Color("#c084fc")
Red = lipgloss.Color("#f87171")

white = lipgloss.Color("#e2e8f0")
text = lipgloss.Color("252")
muted = lipgloss.Color("245")
faint = lipgloss.Color("240")
)

// Structural styles shared by every screen.
var (
// Frame is the outer padding every top-level View should wrap itself in.
Frame = lipgloss.NewStyle().Padding(1, 2)
// Dim is for secondary text (descriptions, metadata).
Dim = lipgloss.NewStyle().Foreground(muted)
// Body is primary readable text.
Body = lipgloss.NewStyle().Foreground(text)
// Danger is for errors and warnings.
Danger = lipgloss.NewStyle().Foreground(Red)

selStyle = lipgloss.NewStyle().Bold(true).Foreground(white)
hintText = lipgloss.NewStyle().Foreground(faint)
keyText = lipgloss.NewStyle().Bold(true).Foreground(muted)
)

// Theme carries one screen's accent color and renders the shared widgets in it.
type Theme struct{ Accent lipgloss.Color }

// New returns a theme that tints titles, sections, card borders, cursors, and
// selected rows with accent (use a palette color).
func New(accent lipgloss.Color) Theme { return Theme{Accent: accent} }

func (t Theme) accentStyle() lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(t.Accent)
}

// Title renders the screen's main heading.
func (t Theme) Title(s string) string { return t.accentStyle().Render(s) }

// Section renders an upper-cased sub-heading inside a screen.
func (t Theme) Section(s string) string { return t.accentStyle().Render(strings.ToUpper(s)) }

// Card frames body in a rounded border tinted with the accent. A non-empty
// title is rendered as a section header at the top of the card.
func (t Theme) Card(title, body string) string {
if title != "" {
body = t.Section(title) + "\n\n" + body
}
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.Accent).
Padding(1, 2).
Render(body)
}

// Row renders one selectable menu line: an accent cursor and bold label when
// selected, with a dimmed description on the next line. An empty desc yields a
// single-line row. The returned string ends in a newline.
func (t Theme) Row(selected bool, label, desc string) string {
cur := " "
if selected {
cur = t.accentStyle().Render("❯ ")
label = selStyle.Render(label)
}
row := cur + label + "\n"
if desc != "" {
row += " " + Dim.Render(desc) + "\n"
}
return row
}

// MenuItem renders one polished menu line shared by the hub and the plugin
// menus (PRD §4.1): an accent cursor and bold label when selected, an optional
// status badge after the label, and — to keep long menus uncluttered — the
// description shown only for the focused row. The result ends in a newline.
func (t Theme) MenuItem(selected bool, label, badge, desc string) string {
name := Body.Render(label)
cur := " "
if selected {
name = selStyle.Render(label)
cur = t.accentStyle().Render("❯ ")
}
if badge != "" {
name += " " + badge
}
out := cur + name + "\n"
if selected && desc != "" {
out += " " + Dim.Render(desc) + "\n"
}
return out
}

// Badge variants.
const (
BadgeOK = "ok"
BadgeInfo = "info"
BadgeGold = "gold"
BadgeWarn = "warn"
BadgeMuted = "muted"
)

// Badge renders a small filled status tag, e.g. Badge(BadgeOK, "guests welcome").
func Badge(variant, label string) string {
var fg, bg lipgloss.Color
switch variant {
case BadgeOK:
fg, bg = lipgloss.Color("#052e16"), Green
case BadgeInfo:
fg, bg = lipgloss.Color("#082f49"), Cyan
case BadgeGold:
fg, bg = lipgloss.Color("#451a03"), Gold
case BadgeWarn:
fg, bg = lipgloss.Color("#450a0a"), Red
default:
fg, bg = lipgloss.Color("#0b1020"), muted
}
return lipgloss.NewStyle().Bold(true).Foreground(fg).Background(bg).Padding(0, 1).Render(label)
}

// KeyBar renders a footer hint, emphasizing the key token of each segment. It
// accepts the conventional " · "-separated form ("↑/↓ move · enter select ·
// q quit") so call sites read naturally; the first word of each segment is
// brightened as the key.
func KeyBar(s string) string {
segs := strings.Split(s, "·")
for i, seg := range segs {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
if parts := strings.SplitN(seg, " ", 2); len(parts) == 2 {
segs[i] = keyText.Render(parts[0]) + hintText.Render(" "+parts[1])
} else {
segs[i] = keyText.Render(seg)
}
}
return strings.Join(segs, hintText.Render(" · "))
}
Loading
Loading