Files
waste-go/cmd/tui/main.go

658 lines
19 KiB
Go
Raw Normal View History

// 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/invite"
"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
reader *lineReader
}
type ipcLineMsg struct{ line []byte }
type connectErrMsg struct{ err error }
type readErrMsg struct{ err error }
// lineReader pumps a TCP connection through a channel so a single bufio.Scanner
// is alive for the lifetime of the connection (avoids read-ahead data loss when
// a new scanner is created on each call).
type lineReader struct {
ch chan []byte
}
func newLineReader(conn net.Conn) *lineReader {
lr := &lineReader{ch: make(chan []byte, 64)}
go func() {
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
b := make([]byte, len(scanner.Bytes()))
copy(b, scanner.Bytes())
lr.ch <- b
}
close(lr.ch)
}()
return lr
}
func (lr *lineReader) next() tea.Cmd {
return func() tea.Msg {
b, ok := <-lr.ch
if !ok {
return readErrMsg{fmt.Errorf("daemon closed connection")}
}
return ipcLineMsg{b}
}
}
// ── 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
reader *lineReader
localID proto.PeerID
localAlias string
rooms []string // "general" always first; DM rooms appended
activeRoom int
messages map[string][]entry
unread map[string]bool // rooms with messages since last viewed
peers map[proto.PeerID]string // connected peers: id → alias
peerOrder []proto.PeerID
input textinput.Model
viewport viewport.Model
vpReady bool
status string
errMsg string
invitePopup string // non-empty = show invite overlay
}
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),
unread: make(map[string]bool),
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: conn, reader: newLineReader(conn)}
}
}
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.reader = msg.reader
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}),
m.reader.next(),
)
case ipcLineMsg:
var evt proto.IpcMessage
if json.Unmarshal(msg.line, &evt) == nil {
m = m.applyEvent(evt)
}
cmds = append(cmds, m.reader.next())
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:
if m.invitePopup != "" {
m.invitePopup = ""
return m, nil
}
return m, tea.Quit
case msg.String() == "ctrl+i":
if m.enc != nil {
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGenerateInvite}))
}
case msg.Type == tea.KeyEnter:
m, cmds = m.doSend(cmds)
case msg.Type == tea.KeyTab:
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
delete(m.unread, m.activeRoomName())
m = m.refreshViewport()
case msg.Type == tea.KeyShiftTab:
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
delete(m.unread, m.activeRoomName())
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)
}
for _, r := range evt.Rooms {
m = m.addRoom(r)
}
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
case proto.EvtRoomCreated:
m = m.addRoom(evt.Room)
m = m.refreshViewport()
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.EvtInviteGenerated:
m.invitePopup = evt.InviteGenerated
case proto.EvtMessageReceived:
if evt.Message != nil {
msg := evt.Message
e := entry{
from: m.aliasOf(msg.From),
Bring wire protocol into full YAW/2 spec compliance Five interop issues fixed against PROTOCOL.md: - Join signature now covers nonce_raw || net_ascii (64-char UTF-8 hex string) as specified in §5.1, not net_raw_bytes. Both anchor server and client updated to match. - Chat wire fields renamed to spec names: text/ts (Unix ms int64) replacing body/sent_at (ISO timestamp). Flat layout on PeerMessage matches §8 exactly; store and TUI updated accordingly. - Direct messages now use the spec "pm" type (flat {type,mid,text,ts}) instead of chat+to. Receiver reconstructs a ChatMessage with dm:<short-id> room for IPC/storage. §8 compliant. - File transfer message types changed to spec hyphenated names: file-offer, file-accept, file-cancel, file-done with spec field names (name/size not filename/size_bytes). §9 compliant. - DataChannel open-race (§14 gotcha #3) fixed with sync.Once: doOpen fires on OnOpen callback or immediately if the channel is already open when WireDataChannel is called (answerer race). Also fixes two bugs found during testing: - mid was missing from outgoing wire messages, causing all received messages to arrive with mid="" and collide on the UNIQUE DB constraint. mid is now included on all sent chat/pm messages; a random mid is generated for any received message that omits it. - Test scripts hardened: kill -9 + active lsof polling replaces blind sleep for port cleanup; join_network sent before peer_field queries (local_peer is now network-scoped and nil until joined). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:38:01 +02:00
body: msg.Text,
at: time.UnixMilli(msg.Ts),
fromMe: msg.From == m.localID,
}
m.messages[msg.Room] = append(m.messages[msg.Room], e)
m = m.addRoom(msg.Room)
if msg.Room != m.activeRoomName() {
m.unread[msg.Room] = true
}
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("")
if strings.HasPrefix(body, "/room ") {
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
if name != "" {
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name}))
}
return m, cmds
}
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 · /room <name>: new room · ctrl+i: invite · ctrl+c: quit"
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
}
view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
// ── invite popup (full-screen overlay) ───────────────────────────────────
if m.invitePopup != "" {
label := styleActive.Render("Invite — share this with anyone you want to add:")
code := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("238")).
Padding(0, 1).
Width(m.width - 6).
Render(m.invitePopup)
hint := styleStatus.Render("Esc to close · ctrl+c to quit")
popup := lipgloss.NewStyle().
Width(m.width).
Height(m.height).
Align(lipgloss.Center, lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, label, "", code, "", hint))
return popup
}
return view
}
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 {
prefix := " "
if m.unread[room] {
prefix = "* "
}
lines = append(lines, styleRoom.Width(innerW).Render(prefix+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")
joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name")
flag.Parse()
// --join overrides --network.
if *joinInvite != "" {
inv, err := invite.Decode(*joinInvite)
if err != nil {
fmt.Fprintln(os.Stderr, "error: invalid invite:", err)
os.Exit(1)
}
*network = inv.Network
}
if *network == "" {
fmt.Fprintln(os.Stderr, "error: -network or -join 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)
}
}