agentbbs/internal/news/tui.go
Anthony Ettinger e0a267e343
Fix/pod public html bind mount (#30)
* feat(pods/admin): root admin alias, default-caps cleanup, pod rebuild script

- auth: add `root` as an admin-console route alias (alongside admin/sysop);
  still gated by $AGENTBBS_ADMINS — the name confers nothing on its own.
- pods: drop the now-redundant tuneApt apt-sandbox hack. Rootless podman keeps
  its default capability set, so apt/chown/su work without disabling the
  download sandbox.
- scripts/rebuild-pods.sh: recreate all member pods (keeps home volumes) so
  they pick up the current container profile on next `ssh pod@`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(arcade,ui): 80s arcade classics + shared menu theme

Arcade games (PRD §5.1), generalizing the sandboxed-PTY DOOM path into an
external-game registry: Space Invaders (nInvaders), Pac-Man (pacman4console),
Tetris (tint/vitetris), Moon Patrol (moon-buggy). Binaries resolve from
assets/bin, PATH, then /usr/games, so a distro install or a hand-built binary
lights each game up; missing games are skipped with a discovery hint. Installed
on the host via `scripts/fetch-assets.sh --arcade` (apt), wired into setup.sh
behind FETCH_ARCADE (default on).

UI: new shared ui.Theme.MenuItem widget (accent cursor + badge, description
shown only for the focused row) adopted by the hub and arcade menus; the hub
groups rows under Features/Sessions headers and the arcade under
DOOM/ARCADE/BUILT-IN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:06:32 -07:00

436 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package news
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
"github.com/dustin/go-nntp"
"github.com/profullstack/agentbbs/internal/ui"
)
var (
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
// newsreader TUI over the SSH session until they leave.
func RunReader(s ssh.Session, addr, user string) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
_, _ = s.Write([]byte("news needs a terminal (ssh -t news@<host>)\r\n"))
return nil
}
r, err := Dial(addr, user)
if err != nil {
return err
}
m := &model{r: r, width: ptyReq.Window.Width, height: ptyReq.Window.Height, mode: modeGroups}
p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
go func() {
for w := range winCh {
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
}
}()
_, runErr := p.Run()
_ = r.Close()
return runErr
}
type mode int
const (
modeGroups mode = iota
modeThreads
modeArticle
modeCompose
)
type model struct {
r *Reader
mode mode
groups []nntp.Group
gSel int
group nntp.Group
threads []Overview
tSel int
article string
aScroll int
// compose
cField int // 0=subject, 1=body
cSubject string
cBody string
cRefs string
status string
width, height int
}
// async message types
type groupsMsg struct {
groups []nntp.Group
err error
}
type threadsMsg struct {
group nntp.Group
threads []Overview
err error
}
type articleMsg struct {
text string
err error
}
type postedMsg struct{ err error }
func (m *model) Init() tea.Cmd { return m.loadGroups }
func (m *model) loadGroups() tea.Msg {
gs, err := m.r.Groups()
return groupsMsg{groups: gs, err: err}
}
func (m *model) loadThreads(name string) tea.Cmd {
return func() tea.Msg {
g, err := m.r.Select(name)
if err != nil {
return threadsMsg{err: err}
}
ov, err := m.r.Overview(g.Low, g.High)
return threadsMsg{group: g, threads: ov, err: err}
}
}
func (m *model) loadArticle(num int64) tea.Cmd {
return func() tea.Msg {
txt, err := m.r.Article(num)
return articleMsg{text: txt, err: err}
}
}
func (m *model) submitPost() tea.Cmd {
group, subject, refs, body := m.group.Name, strings.TrimSpace(m.cSubject), m.cRefs, m.cBody
return func() tea.Msg {
if subject == "" {
subject = "(no subject)"
}
return postedMsg{err: m.r.Post(group, subject, refs, body)}
}
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case groupsMsg:
if msg.err != nil {
m.status = "groups: " + msg.err.Error()
} else {
m.groups = msg.groups
}
case threadsMsg:
if msg.err != nil {
m.status = "open group: " + msg.err.Error()
m.mode = modeGroups
} else {
m.group, m.threads, m.tSel, m.mode = msg.group, msg.threads, 0, modeThreads
}
case articleMsg:
if msg.err != nil {
m.status = "open article: " + msg.err.Error()
} else {
m.article, m.aScroll, m.mode = msg.text, 0, modeArticle
}
case postedMsg:
if msg.err != nil {
m.status = "post failed: " + msg.err.Error()
return m, nil
}
m.status = "posted to " + m.group.Name
m.mode = modeThreads
return m, m.loadThreads(m.group.Name)
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.mode == modeCompose {
return m.composeKey(msg)
}
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
}
switch m.mode {
case modeGroups:
switch msg.String() {
case "q", "esc":
return m, tea.Quit
case "up", "k":
if m.gSel > 0 {
m.gSel--
}
case "down", "j":
if m.gSel < len(m.groups)-1 {
m.gSel++
}
case "enter", "right", "l":
if len(m.groups) > 0 {
return m, m.loadThreads(m.groups[m.gSel].Name)
}
}
case modeThreads:
switch msg.String() {
case "q", "esc", "left", "h":
m.mode = modeGroups
case "up", "k":
if m.tSel > 0 {
m.tSel--
}
case "down", "j":
if m.tSel < len(m.threads)-1 {
m.tSel++
}
case "enter", "right", "l":
if len(m.threads) > 0 {
return m, m.loadArticle(m.threads[m.tSel].Num)
}
case "p":
m.startCompose("", "")
}
case modeArticle:
switch msg.String() {
case "q", "esc", "left", "h":
m.mode = modeThreads
case "up", "k":
if m.aScroll > 0 {
m.aScroll--
}
case "down", "j":
m.aScroll++
case "r":
cur := m.threads[m.tSel]
subj := cur.Subject
if !strings.HasPrefix(strings.ToLower(subj), "re:") {
subj = "Re: " + subj
}
refs := strings.TrimSpace(cur.Refs + " " + cur.MsgID)
m.startCompose(subj, refs)
}
}
return m, nil
}
func (m *model) startCompose(subject, refs string) {
m.mode = modeCompose
m.cField = 0
m.cSubject = subject
m.cBody = ""
m.cRefs = refs
}
func (m *model) composeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeThreads
m.status = "compose cancelled"
return m, nil
case "ctrl+s":
return m, m.submitPost()
case "tab":
m.cField = (m.cField + 1) % 2
return m, nil
}
if m.cField == 0 { // subject
switch msg.String() {
case "enter":
m.cField = 1
case "backspace":
if len(m.cSubject) > 0 {
m.cSubject = m.cSubject[:len(m.cSubject)-1]
}
default:
if msg.Type == tea.KeyRunes || msg.String() == " " {
m.cSubject += string(msg.Runes)
}
}
return m, nil
}
// body
switch msg.String() {
case "enter":
m.cBody += "\n"
case "backspace":
if len(m.cBody) > 0 {
m.cBody = m.cBody[:len(m.cBody)-1]
}
default:
if msg.Type == tea.KeyRunes || msg.String() == " " {
m.cBody += string(msg.Runes)
}
}
return m, nil
}
func (m *model) View() string {
switch m.mode {
case modeGroups:
return m.viewGroups()
case modeThreads:
return m.viewThreads()
case modeArticle:
return m.viewArticle()
case modeCompose:
return m.viewCompose()
}
return ""
}
func (m *model) rows() int {
r := m.height - 4
if r < 3 {
r = 3
}
return r
}
func (m *model) frame(header, body, hint string) string {
status := ""
if m.status != "" {
status = "\n" + nMeta.Render(m.status)
}
return lipgloss.NewStyle().Padding(0, 1).Render(
theme.Title(header) + "\n\n" + body + status + "\n\n" + ui.KeyBar(hint))
}
func (m *model) viewGroups() string {
var b strings.Builder
for i, g := range m.groups {
line := fmt.Sprintf("%-24s %5d articles", g.Name, g.High)
if i == m.gSel {
line = nSel.Render(" " + line)
} else {
line = " " + line
}
b.WriteString(line + "\n")
}
if len(m.groups) == 0 {
b.WriteString(nMeta.Render("no groups yet"))
}
return m.frame("news@ "+m.r.User()+" — newsgroups", b.String(),
"↑/↓ move · enter open · q quit")
}
func (m *model) viewThreads() string {
var b strings.Builder
start, rows := scrollStart(m.tSel, len(m.threads), m.rows())
for i := start; i < len(m.threads) && i < start+rows; i++ {
t := m.threads[i]
line := fmt.Sprintf("%-50s %s", truncate(t.Subject, 50), nFrom.Render(shortFrom(t.From)))
if i == m.tSel {
line = nSel.Render(" ") + line
} else {
line = " " + line
}
b.WriteString(line + "\n")
}
if len(m.threads) == 0 {
b.WriteString(nMeta.Render("no articles — press p to post the first one"))
}
return m.frame("news@ — "+m.group.Name, b.String(),
"↑/↓ move · enter read · p post · esc groups")
}
func (m *model) viewArticle() string {
lines := strings.Split(strings.ReplaceAll(m.article, "\r\n", "\n"), "\n")
rows := m.rows()
if m.aScroll > len(lines)-1 {
m.aScroll = max(0, len(lines)-1)
}
end := m.aScroll + rows
if end > len(lines) {
end = len(lines)
}
body := strings.Join(lines[m.aScroll:end], "\n")
return m.frame("news@ — "+m.group.Name, body,
"↑/↓ scroll · r reply · esc back")
}
func (m *model) viewCompose() string {
subjLabel, bodyLabel := " Subject: ", " Body:"
if m.cField == 0 {
subjLabel = nSel.Render(" Subject: ")
} else {
bodyLabel = nSel.Render(" Body:")
}
cursor := ""
if m.cField == 0 {
cursor = "█"
}
body := subjLabel + m.cSubject + cursor + "\n" + bodyLabel + "\n"
bodyText := m.cBody
if m.cField == 1 {
bodyText += "█"
}
body += indent(bodyText)
header := "compose → " + m.group.Name
if m.cRefs != "" {
header = "reply → " + m.group.Name
}
return m.frame(header, body, "tab switch field · ctrl+s send · esc cancel")
}
func scrollStart(sel, n, rows int) (int, int) {
if n <= rows {
return 0, rows
}
start := sel - rows/2
if start < 0 {
start = 0
}
if start > n-rows {
start = n - rows
}
return start, rows
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 1 {
return s[:n]
}
return s[:n-1] + "…"
}
// shortFrom renders just the display name (or local part) of a From header.
func shortFrom(from string) string {
if i := strings.IndexByte(from, '<'); i > 0 {
return strings.TrimSpace(from[:i])
}
if i := strings.IndexByte(from, '@'); i > 0 {
return from[:i]
}
return from
}
func indent(s string) string {
var b strings.Builder
for _, l := range strings.Split(s, "\n") {
b.WriteString(" " + l + "\n")
}
return b.String()
}