- 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>
567 lines
16 KiB
Go
567 lines
16 KiB
Go
// 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)
|
|
}
|
|
}
|