// 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 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), 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) 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.EvtInviteGenerated: m.invitePopup = evt.InviteGenerated case proto.EvtMessageReceived: if evt.Message != nil { msg := evt.Message e := entry{ from: m.aliasOf(msg.From), 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) 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 · 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 { 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") 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) } }