Add Bubble Tea TUI (cmd/tui) with three-pane layout
- Connects to a running daemon's IPC port on startup - Sends join_network then get_state; listens for events in real time - Three-pane layout: room list (left), message history (centre), peers (right) - Tab/Shift+Tab to switch rooms, Enter to send, PgUp/PgDn to scroll - DM rooms appear automatically when a DM arrives - test-tui.sh boots the full stack (anchor + 3 peers) and opens the TUI as alice, with bob and charlie sending periodic messages as live noise - README: layout diagram, key bindings, TUI section; roadmap item marked done Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
566
cmd/tui/main.go
Normal file
566
cmd/tui/main.go
Normal file
@@ -0,0 +1,566 @@
|
||||
// Package main is the waste-go terminal UI.
|
||||
// It connects to a running daemon's IPC port, joins a named network, and
|
||||
// renders a three-pane layout: rooms (left), messages (centre), peers (right).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// ── styles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
||||
styleActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
||||
styleRoom = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
|
||||
stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72"))
|
||||
styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
|
||||
styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
||||
styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
||||
styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
||||
styleBorder = lipgloss.Color("238")
|
||||
styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||
styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
|
||||
)
|
||||
|
||||
const sideW = 22 // total width of each sidebar box (inner = sideW-2)
|
||||
|
||||
// ── tea messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
type connectedMsg struct{ conn net.Conn }
|
||||
type ipcLineMsg struct{ line []byte }
|
||||
type connectErrMsg struct{ err error }
|
||||
type readErrMsg struct{ err error }
|
||||
|
||||
// ── model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type entry struct {
|
||||
from string
|
||||
body string
|
||||
at time.Time
|
||||
fromMe bool
|
||||
}
|
||||
|
||||
type model struct {
|
||||
ipcPort int
|
||||
networkName string
|
||||
|
||||
width, height int
|
||||
|
||||
conn net.Conn
|
||||
enc *json.Encoder
|
||||
|
||||
localID proto.PeerID
|
||||
localAlias string
|
||||
|
||||
rooms []string // "general" always first; DM rooms appended
|
||||
activeRoom int
|
||||
messages map[string][]entry
|
||||
|
||||
peers map[proto.PeerID]string // connected peers: id → alias
|
||||
peerOrder []proto.PeerID
|
||||
|
||||
input textinput.Model
|
||||
viewport viewport.Model
|
||||
vpReady bool
|
||||
|
||||
status string
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func newModel(ipcPort int, network string) model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Type a message…"
|
||||
ti.Focus()
|
||||
ti.CharLimit = 2000
|
||||
|
||||
return model{
|
||||
ipcPort: ipcPort,
|
||||
networkName: network,
|
||||
rooms: []string{"general"},
|
||||
messages: make(map[string][]entry),
|
||||
peers: make(map[proto.PeerID]string),
|
||||
input: ti,
|
||||
status: "connecting…",
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(connectCmd(m.ipcPort), textinput.Blink)
|
||||
}
|
||||
|
||||
// ── commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func connectCmd(port int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
return connectErrMsg{err}
|
||||
}
|
||||
return connectedMsg{conn}
|
||||
}
|
||||
}
|
||||
|
||||
func listenCmd(conn net.Conn) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
scanner := bufio.NewScanner(conn)
|
||||
if scanner.Scan() {
|
||||
b := make([]byte, len(scanner.Bytes()))
|
||||
copy(b, scanner.Bytes())
|
||||
return ipcLineMsg{b}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return readErrMsg{err}
|
||||
}
|
||||
return readErrMsg{fmt.Errorf("daemon closed connection")}
|
||||
}
|
||||
}
|
||||
|
||||
func sendIPC(enc *json.Encoder, msg proto.IpcMessage) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
enc.Encode(msg) //nolint:errcheck
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
m = m.resizeViewport()
|
||||
|
||||
case connectErrMsg:
|
||||
m.errMsg = fmt.Sprintf("cannot connect to daemon on :%d — is it running?", m.ipcPort)
|
||||
m.status = "disconnected"
|
||||
|
||||
case connectedMsg:
|
||||
m.conn = msg.conn
|
||||
m.enc = json.NewEncoder(msg.conn)
|
||||
m.status = "joining " + m.networkName + "…"
|
||||
cmds = append(cmds,
|
||||
sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.networkName}),
|
||||
sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}),
|
||||
listenCmd(m.conn),
|
||||
)
|
||||
|
||||
case ipcLineMsg:
|
||||
var evt proto.IpcMessage
|
||||
if json.Unmarshal(msg.line, &evt) == nil {
|
||||
m = m.applyEvent(evt)
|
||||
}
|
||||
cmds = append(cmds, listenCmd(m.conn))
|
||||
|
||||
case readErrMsg:
|
||||
m.errMsg = "IPC disconnected: " + msg.err.Error()
|
||||
m.status = "disconnected"
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case msg.Type == tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case msg.Type == tea.KeyEsc:
|
||||
return m, tea.Quit
|
||||
case msg.Type == tea.KeyEnter:
|
||||
m, cmds = m.doSend(cmds)
|
||||
case msg.Type == tea.KeyTab:
|
||||
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
||||
m = m.refreshViewport()
|
||||
case msg.Type == tea.KeyShiftTab:
|
||||
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
||||
m = m.refreshViewport()
|
||||
default:
|
||||
var tiCmd tea.Cmd
|
||||
m.input, tiCmd = m.input.Update(msg)
|
||||
cmds = append(cmds, tiCmd)
|
||||
}
|
||||
|
||||
default:
|
||||
// Let viewport handle scroll events.
|
||||
if m.vpReady {
|
||||
var vpCmd tea.Cmd
|
||||
m.viewport, vpCmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, vpCmd)
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
switch evt.Type {
|
||||
|
||||
case proto.EvtStateSnapshot:
|
||||
if evt.LocalPeer != nil {
|
||||
m.localID = evt.LocalPeer.ID
|
||||
m.localAlias = evt.LocalPeer.Alias
|
||||
}
|
||||
m.peers = make(map[proto.PeerID]string)
|
||||
m.peerOrder = nil
|
||||
for _, p := range evt.ConnectedPeers {
|
||||
m.peers[p.ID] = p.Alias
|
||||
m.peerOrder = append(m.peerOrder, p.ID)
|
||||
}
|
||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||
|
||||
case proto.EvtSessionReady:
|
||||
if evt.PeerID != nil {
|
||||
pid := *evt.PeerID
|
||||
if _, ok := m.peers[pid]; !ok {
|
||||
m.peerOrder = append(m.peerOrder, pid)
|
||||
}
|
||||
alias := evt.Nick
|
||||
if alias == "" {
|
||||
alias = shortID(pid)
|
||||
}
|
||||
m.peers[pid] = alias
|
||||
}
|
||||
|
||||
case proto.EvtPeerConnected:
|
||||
if evt.Peer != nil {
|
||||
pid := evt.Peer.ID
|
||||
if _, ok := m.peers[pid]; !ok {
|
||||
m.peerOrder = append(m.peerOrder, pid)
|
||||
}
|
||||
m.peers[pid] = evt.Peer.Alias
|
||||
}
|
||||
|
||||
case proto.EvtPeerDisconnected:
|
||||
if evt.PeerID != nil {
|
||||
pid := *evt.PeerID
|
||||
delete(m.peers, pid)
|
||||
m.peerOrder = filterIDs(m.peerOrder, pid)
|
||||
}
|
||||
|
||||
case proto.EvtMessageReceived:
|
||||
if evt.Message != nil {
|
||||
msg := evt.Message
|
||||
e := entry{
|
||||
from: m.aliasOf(msg.From),
|
||||
body: msg.Body,
|
||||
at: msg.SentAt,
|
||||
fromMe: msg.From == m.localID,
|
||||
}
|
||||
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
||||
m = m.addRoom(msg.Room)
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
||||
body := strings.TrimSpace(m.input.Value())
|
||||
if body == "" || m.enc == nil {
|
||||
return m, cmds
|
||||
}
|
||||
m.input.SetValue("")
|
||||
|
||||
room := m.rooms[m.activeRoom]
|
||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
recipID := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
ipcMsg.To = &recipID
|
||||
}
|
||||
cmds = append(cmds, sendIPC(m.enc, ipcMsg))
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
// ── layout helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// vpContentWidth returns the inner width of the centre pane (available to the viewport).
|
||||
func (m model) vpContentWidth() int {
|
||||
// Two sidebar boxes (sideW total each) + centre box (borders 2 = -2 from inner).
|
||||
w := m.width - sideW*2 - 2
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// vpHeight returns the viewport height (lines of messages shown).
|
||||
func (m model) vpHeight() int {
|
||||
// Full height minus: top border(1) + title(1) + divider(1) + bottom border(1) +
|
||||
// input box (3 lines incl borders) + status bar(1) = 8 total overhead.
|
||||
h := m.height - 8
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m model) resizeViewport() model {
|
||||
if !m.vpReady {
|
||||
m.viewport = viewport.New(m.vpContentWidth(), m.vpHeight())
|
||||
m.vpReady = true
|
||||
} else {
|
||||
m.viewport.Width = m.vpContentWidth()
|
||||
m.viewport.Height = m.vpHeight()
|
||||
}
|
||||
return m.refreshViewport()
|
||||
}
|
||||
|
||||
func (m model) refreshViewport() model {
|
||||
if !m.vpReady {
|
||||
return m
|
||||
}
|
||||
room := m.activeRoomName()
|
||||
w := m.vpContentWidth()
|
||||
var sb strings.Builder
|
||||
for _, e := range m.messages[room] {
|
||||
ts := styleMsgTime.Render(e.at.Format("15:04"))
|
||||
var from string
|
||||
if e.fromMe {
|
||||
from = styleMsgMe.Render(e.from)
|
||||
} else {
|
||||
from = styleMsgFrom.Render(e.from)
|
||||
}
|
||||
line := fmt.Sprintf("%s %s %s", ts, from, e.body)
|
||||
// Crude wrap: if line > w, just truncate (viewport handles horizontal scroll).
|
||||
_ = w
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
m.viewport.SetContent(sb.String())
|
||||
m.viewport.GotoBottom()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) activeRoomName() string {
|
||||
if m.activeRoom < len(m.rooms) {
|
||||
return m.rooms[m.activeRoom]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m model) addRoom(room string) model {
|
||||
for _, r := range m.rooms {
|
||||
if r == room {
|
||||
return m
|
||||
}
|
||||
}
|
||||
m.rooms = append(m.rooms, room)
|
||||
return m
|
||||
}
|
||||
|
||||
// ── view ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) View() string {
|
||||
if m.width == 0 {
|
||||
return "loading…\n"
|
||||
}
|
||||
|
||||
innerH := m.height - 3 - 1 // 3 = input box, 1 = status bar
|
||||
if innerH < 4 {
|
||||
innerH = 4
|
||||
}
|
||||
|
||||
// ── left: rooms ───────────────────────────────────────────────────────────
|
||||
leftBox := m.renderRooms(innerH)
|
||||
|
||||
// ── right: peers ──────────────────────────────────────────────────────────
|
||||
rightBox := m.renderPeers(innerH)
|
||||
|
||||
// ── centre: title + messages ──────────────────────────────────────────────
|
||||
centreBox := m.renderCentre(innerH)
|
||||
|
||||
mainRow := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, centreBox, rightBox)
|
||||
|
||||
// ── input ─────────────────────────────────────────────────────────────────
|
||||
inputBox := lipgloss.NewStyle().
|
||||
Width(m.width - 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(styleBorder).
|
||||
Render(m.input.View())
|
||||
|
||||
// ── status bar ────────────────────────────────────────────────────────────
|
||||
var statusLine string
|
||||
if m.errMsg != "" {
|
||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||
} else {
|
||||
hint := " tab: rooms · pgup/dn: scroll · ctrl+c: quit"
|
||||
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
|
||||
}
|
||||
|
||||
func (m model) renderRooms(boxH int) string {
|
||||
innerW := sideW - 2
|
||||
contentH := boxH - 2 // subtract top+bottom border
|
||||
var lines []string
|
||||
lines = append(lines, styleHeader.Width(innerW).Render("Rooms"))
|
||||
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)))
|
||||
for i, room := range m.rooms {
|
||||
label := roomLabel(room, m.peers)
|
||||
if i == m.activeRoom {
|
||||
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
||||
} else {
|
||||
lines = append(lines, styleRoom.Width(innerW).Render(" "+label))
|
||||
}
|
||||
}
|
||||
for len(lines) < contentH {
|
||||
lines = append(lines, strings.Repeat(" ", innerW))
|
||||
}
|
||||
content := strings.Join(lines[:min(len(lines), contentH)], "\n")
|
||||
return lipgloss.NewStyle().
|
||||
Width(sideW - 2).Height(boxH - 2).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
func (m model) renderPeers(boxH int) string {
|
||||
innerW := sideW - 2
|
||||
contentH := boxH - 2
|
||||
var lines []string
|
||||
lines = append(lines, styleHeader.Width(innerW).Render("Peers"))
|
||||
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)))
|
||||
// Local peer first
|
||||
if m.localAlias != "" {
|
||||
lines = append(lines, styleSelf.Width(innerW).Render("◉ "+m.localAlias+" (me)"))
|
||||
}
|
||||
for _, pid := range m.peerOrder {
|
||||
alias := m.peers[pid]
|
||||
if alias == "" {
|
||||
alias = shortID(pid)
|
||||
}
|
||||
lines = append(lines, stylePeer.Width(innerW).Render("● "+alias))
|
||||
}
|
||||
for len(lines) < contentH {
|
||||
lines = append(lines, strings.Repeat(" ", innerW))
|
||||
}
|
||||
content := strings.Join(lines[:min(len(lines), contentH)], "\n")
|
||||
return lipgloss.NewStyle().
|
||||
Width(sideW - 2).Height(boxH - 2).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
func (m model) renderCentre(boxH int) string {
|
||||
innerW := m.vpContentWidth()
|
||||
room := m.activeRoomName()
|
||||
title := styleTitle.Width(innerW).Render(" " + roomTitle(room, m.peers))
|
||||
divider := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))
|
||||
|
||||
vpView := ""
|
||||
if m.vpReady {
|
||||
vpView = m.viewport.View()
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, title, divider, vpView)
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Width(innerW).Height(boxH - 2).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) aliasOf(id proto.PeerID) string {
|
||||
if id == m.localID {
|
||||
if m.localAlias != "" {
|
||||
return m.localAlias
|
||||
}
|
||||
return "me"
|
||||
}
|
||||
if a, ok := m.peers[id]; ok && a != "" {
|
||||
return a
|
||||
}
|
||||
return shortID(id)
|
||||
}
|
||||
|
||||
func shortID(id proto.PeerID) string {
|
||||
s := string(id)
|
||||
if len(s) > 8 {
|
||||
return s[:8]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func roomLabel(room string, peers map[proto.PeerID]string) string {
|
||||
if room == "general" {
|
||||
return "#general"
|
||||
}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
if a, ok := peers[pid]; ok && a != "" {
|
||||
return "@ " + a
|
||||
}
|
||||
return "@ " + shortID(pid)
|
||||
}
|
||||
return "#" + room
|
||||
}
|
||||
|
||||
func roomTitle(room string, peers map[proto.PeerID]string) string {
|
||||
if room == "general" {
|
||||
return "#general"
|
||||
}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
if a, ok := peers[pid]; ok && a != "" {
|
||||
return "@ " + a
|
||||
}
|
||||
return "@ " + shortID(pid)
|
||||
}
|
||||
return "#" + room
|
||||
}
|
||||
|
||||
func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
|
||||
out := ids[:0]
|
||||
for _, v := range ids {
|
||||
if v != remove {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
|
||||
network := flag.String("network", "", "network name to join on startup (required)")
|
||||
flag.Parse()
|
||||
|
||||
if *network == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: -network is required")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(
|
||||
newModel(*ipcPort, *network),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tui:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user