// Package main is the waste-go terminal UI. // It connects to a running daemon's IPC port and renders a three-pane layout: // rooms/networks (left), messages with line numbers (centre), peers (right). // Multiple networks are supported at runtime via /join; switch with ctrl+n. package main import ( "bufio" "encoding/json" "flag" "fmt" "net" "os" "strconv" "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")) styleNet = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) styleNetActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("51")) 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")) styleLineNum = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) styleReaction = lipgloss.NewStyle().Foreground(lipgloss.Color("246")) 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 // ── 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 } 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 ───────────────────────────────────────────────────────────────────── // netData holds per-network state. type netData struct { id string name string localID proto.PeerID localAlias string rooms []string activeRoom int peers map[proto.PeerID]string peerOrder []proto.PeerID knownPeers map[proto.PeerID]string // historical peers (from store) } func newNetData(id, name string) *netData { return &netData{ id: id, name: name, rooms: []string{"general"}, peers: make(map[proto.PeerID]string), knownPeers: make(map[proto.PeerID]string), } } func (n *netData) addRoom(room string) bool { for _, r := range n.rooms { if r == room { return false } } n.rooms = append(n.rooms, room) return true } // entry is a single chat message in the viewport. type entry struct { mid string from string body string at time.Time fromMe bool } type model struct { ipcPort int initialNetwork string // from -network flag; joined on first connect width, height int conn net.Conn enc *json.Encoder reader *lineReader nets []*netData activeNet int // keyed by "netId:room" messages map[string][]entry unread map[string]bool // mid → emoji → []alias reactions map[string]map[string][]string input textinput.Model viewport viewport.Model vpReady bool status string errMsg string invitePopup string } func newModel(ipcPort int, network string) model { ti := textinput.New() ti.Placeholder = "Type a message, or /join /net /room /react…" ti.Focus() ti.CharLimit = 2000 return model{ ipcPort: ipcPort, initialNetwork: network, messages: make(map[string][]entry), unread: make(map[string]bool), reactions: make(map[string]map[string][]string), input: ti, status: "connecting…", } } // ── accessors ───────────────────────────────────────────────────────────────── func (m model) activeNetData() *netData { if m.activeNet < len(m.nets) { return m.nets[m.activeNet] } return nil } func (m model) activeNetworkID() string { if n := m.activeNetData(); n != nil { return n.id } return "" } func (m model) activeRoomName() string { n := m.activeNetData() if n == nil { return "general" } if n.activeRoom < len(n.rooms) { return n.rooms[n.activeRoom] } return "general" } func (m model) msgKey() string { n := m.activeNetData() if n == nil { return ":general" } return n.id + ":" + n.rooms[n.activeRoom] } func (m model) netByID(id string) *netData { for _, n := range m.nets { if n.id == id { return n } } return nil } func (m model) aliasOf(netID string, id proto.PeerID) string { n := m.netByID(netID) if n == nil { return shortID(id) } if id == n.localID && n.localAlias != "" { return n.localAlias } if a, ok := n.peers[id]; ok && a != "" { return a } if a, ok := n.knownPeers[id]; ok && a != "" { return a } return shortID(id) } // ── Init ────────────────────────────────────────────────────────────────────── 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 cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}), m.reader.next()) if m.initialNetwork != "" { m.status = "joining " + m.initialNetwork + "…" cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.initialNetwork})) } else { m.status = "connected — /join to start" } 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, NetworkID: m.activeNetworkID(), })) } case msg.String() == "ctrl+n": if len(m.nets) > 1 { m.activeNet = (m.activeNet + 1) % len(m.nets) m = m.refreshViewport() } case msg.Type == tea.KeyEnter: m, cmds = m.doSend(cmds) case msg.Type == tea.KeyTab: if n := m.activeNetData(); n != nil { n.activeRoom = (n.activeRoom + 1) % len(n.rooms) delete(m.unread, m.msgKey()) m = m.refreshViewport() } case msg.Type == tea.KeyShiftTab: if n := m.activeNetData(); n != nil { n.activeRoom = (n.activeRoom - 1 + len(n.rooms)) % len(n.rooms) delete(m.unread, m.msgKey()) m = m.refreshViewport() } default: var tiCmd tea.Cmd m.input, tiCmd = m.input.Update(msg) cmds = append(cmds, tiCmd) } default: if m.vpReady { var vpCmd tea.Cmd m.viewport, vpCmd = m.viewport.Update(msg) cmds = append(cmds, vpCmd) } } return m, tea.Batch(cmds...) } // ── applyEvent ──────────────────────────────────────────────────────────────── func (m model) applyEvent(evt proto.IpcMessage) model { switch evt.Type { case proto.EvtStateSnapshot: // Populate nets from snapshot. for _, ni := range evt.Networks { n := m.netByID(ni.NetworkID) if n == nil { n = newNetData(ni.NetworkID, ni.NetworkName) m.nets = append(m.nets, n) } if ni.LocalPeer != nil { n.localID = ni.LocalPeer.ID n.localAlias = ni.LocalPeer.Alias } } // Backward-compat: connected_peers and rooms are from first network. if len(evt.Networks) > 0 && len(m.nets) > 0 { n := m.nets[0] for _, p := range evt.ConnectedPeers { if _, ok := n.peers[p.ID]; !ok { n.peerOrder = append(n.peerOrder, p.ID) } n.peers[p.ID] = p.Alias } for _, r := range evt.Rooms { n.addRoom(r) } for _, p := range evt.KnownPeers { n.knownPeers[p.ID] = p.Alias } } m = m.updateStatus() m = m.refreshViewport() case proto.EvtNetworkJoined: n := m.netByID(evt.NetworkID) if n == nil { name := evt.NetworkName if name == "" { name = evt.NetworkID } n = newNetData(evt.NetworkID, name) m.nets = append(m.nets, n) m.activeNet = len(m.nets) - 1 } if evt.LocalPeer != nil { n.localID = evt.LocalPeer.ID n.localAlias = evt.LocalPeer.Alias } m = m.updateStatus() m = m.refreshViewport() case proto.EvtRoomCreated: if n := m.netByID(evt.NetworkID); n != nil { n.addRoom(evt.Room) m = m.refreshViewport() } case proto.EvtSessionReady: netID := evt.NetworkID if netID == "" && len(m.nets) > 0 { netID = m.nets[0].id } if n := m.netByID(netID); n != nil && evt.PeerID != nil { pid := *evt.PeerID if _, ok := n.peers[pid]; !ok { n.peerOrder = append(n.peerOrder, pid) } alias := evt.Nick if alias == "" { alias = shortID(pid) } n.peers[pid] = alias } case proto.EvtPeerConnected: netID := evt.NetworkID if netID == "" && len(m.nets) > 0 { netID = m.nets[0].id } if n := m.netByID(netID); n != nil && evt.Peer != nil { pid := evt.Peer.ID if _, ok := n.peers[pid]; !ok { n.peerOrder = append(n.peerOrder, pid) } n.peers[pid] = evt.Peer.Alias } case proto.EvtPeerDisconnected: netID := evt.NetworkID if netID == "" && len(m.nets) > 0 { netID = m.nets[0].id } if n := m.netByID(netID); n != nil && evt.PeerID != nil { pid := *evt.PeerID delete(n.peers, pid) n.peerOrder = filterIDs(n.peerOrder, pid) } case proto.EvtInviteGenerated: m.invitePopup = evt.InviteGenerated case proto.EvtMessageReceived: if evt.Message != nil { msg := evt.Message netID := evt.NetworkID if netID == "" && len(m.nets) > 0 { netID = m.nets[0].id } n := m.netByID(netID) if n != nil { n.addRoom(msg.Room) } e := entry{ mid: msg.Mid, from: m.aliasOf(netID, msg.From), body: msg.Text, at: time.UnixMilli(msg.Ts), fromMe: n != nil && msg.From == n.localID, } key := netID + ":" + msg.Room m.messages[key] = append(m.messages[key], e) if key != m.msgKey() { m.unread[key] = true } m = m.refreshViewport() } case proto.EvtHistoryLoaded: netID := evt.NetworkID if netID == "" && len(m.nets) > 0 { netID = m.nets[0].id } n := m.netByID(netID) key := netID + ":" + evt.Room existing := m.messages[key] existingMids := make(map[string]bool, len(existing)) for _, e := range existing { if e.mid != "" { existingMids[e.mid] = true } } var fresh []entry for _, msg := range evt.Messages { if msg.Mid != "" && existingMids[msg.Mid] { continue } fromMe := n != nil && msg.From == n.localID fresh = append(fresh, entry{ mid: msg.Mid, from: m.aliasOf(netID, msg.From), body: msg.Text, at: time.UnixMilli(msg.Ts), fromMe: fromMe, }) } if len(fresh) > 0 { // Prepend history, then existing live messages; sort by time. merged := append(fresh, existing...) // Simple insertion sort (lists are already mostly sorted). for i := 1; i < len(merged); i++ { for j := i; j > 0 && merged[j].at.Before(merged[j-1].at); j-- { merged[j], merged[j-1] = merged[j-1], merged[j] } } m.messages[key] = merged if n != nil { n.addRoom(evt.Room) } m = m.refreshViewport() } case proto.EvtReaction: mid := evt.ReactionMID emoji := evt.ReactionEmoji if mid == "" || emoji == "" || evt.PeerID == nil { break } netID := evt.NetworkID if netID == "" && len(m.nets) > 0 { netID = m.nets[0].id } alias := m.aliasOf(netID, *evt.PeerID) if m.reactions[mid] == nil { m.reactions[mid] = make(map[string][]string) } for _, a := range m.reactions[mid][emoji] { if a == alias { return m // already recorded } } m.reactions[mid][emoji] = append(m.reactions[mid][emoji], alias) m = m.refreshViewport() } return m } func (m model) updateStatus() model { n := m.activeNetData() if n == nil { m.status = "connected — /join to start" return m } m.status = fmt.Sprintf("● %s · %s", n.localAlias, n.name) return m } // ── doSend ──────────────────────────────────────────────────────────────────── 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("") // /join if strings.HasPrefix(body, "/join ") { name := strings.TrimSpace(strings.TrimPrefix(body, "/join ")) if name != "" { cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: name})) } return m, cmds } // /net — switch active network if strings.HasPrefix(body, "/net ") { arg := strings.TrimSpace(strings.TrimPrefix(body, "/net ")) if n, err := strconv.Atoi(arg); err == nil { idx := n - 1 if idx >= 0 && idx < len(m.nets) { m.activeNet = idx m = m.updateStatus() m = m.refreshViewport() } } else { for i, net := range m.nets { if strings.EqualFold(net.name, arg) { m.activeNet = i m = m.updateStatus() m = m.refreshViewport() break } } } return m, cmds } // /room 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, NetworkID: m.activeNetworkID(), Room: name, })) } return m, cmds } // /react [] if strings.HasPrefix(body, "/react ") { rest := strings.TrimSpace(strings.TrimPrefix(body, "/react ")) parts := strings.Fields(rest) var targetIdx int = -1 // -1 = last message var emoji string switch len(parts) { case 1: emoji = parts[0] case 2: if n, err := strconv.Atoi(parts[0]); err == nil { targetIdx = n - 1 } else { emoji = parts[0] // fallback: treat first token as emoji } if emoji == "" { emoji = parts[1] } } if emoji == "" { m.errMsg = "usage: /react or /react (e.g. /react 👍 or /react 3 ❤️)" return m, cmds } msgs := m.messages[m.msgKey()] var targetMid string if targetIdx == -1 && len(msgs) > 0 { targetMid = msgs[len(msgs)-1].mid } else if targetIdx >= 0 && targetIdx < len(msgs) { targetMid = msgs[targetIdx].mid } if targetMid == "" { m.errMsg = "usage: /react or /react (e.g. /react 👍 or /react 3 ❤️)" return m, cmds } cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{ Type: proto.CmdSendReaction, NetworkID: m.activeNetworkID(), ReactionMID: targetMid, ReactionEmoji: emoji, })) return m, cmds } // Regular message n := m.activeNetData() if n == nil { return m, cmds } room := n.rooms[n.activeRoom] ipcMsg := proto.IpcMessage{ Type: proto.CmdSendMessage, NetworkID: n.id, 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 ──────────────────────────────────────────────────────────── func (m model) vpContentWidth() int { w := m.width - sideW*2 - 2 if w < 10 { w = 10 } return w } func (m model) vpHeight() int { 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 } key := m.msgKey() var sb strings.Builder for i, e := range m.messages[key] { lineNum := styleLineNum.Render(fmt.Sprintf("[%d]", i+1)) ts := styleMsgTime.Render(formatMsgTime(e.at)) var from string if e.fromMe { from = styleMsgMe.Render(e.from) } else { from = styleMsgFrom.Render(e.from) } sb.WriteString(fmt.Sprintf("%s %s %s %s\n", lineNum, ts, from, e.body)) if e.mid != "" { if rxn := m.renderReactions(e.mid); rxn != "" { sb.WriteString(rxn + "\n") } } } m.viewport.SetContent(sb.String()) m.viewport.GotoBottom() return m } func (m model) renderReactions(mid string) string { byEmoji := m.reactions[mid] if len(byEmoji) == 0 { return "" } var parts []string for emoji, froms := range byEmoji { parts = append(parts, fmt.Sprintf("%s %d", emoji, len(froms))) } return styleReaction.Render(" " + strings.Join(parts, " ")) } // ── view ────────────────────────────────────────────────────────────────────── func (m model) View() string { if m.width == 0 { return "loading…\n" } innerH := m.height - 3 - 1 if innerH < 4 { innerH = 4 } leftBox := m.renderLeft(innerH) rightBox := m.renderPeers(innerH) centreBox := m.renderCentre(innerH) mainRow := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, centreBox, rightBox) inputBox := lipgloss.NewStyle(). Width(m.width - 2). Border(lipgloss.RoundedBorder()). BorderForeground(styleBorder). Render(m.input.View()) var statusLine string if m.errMsg != "" { statusLine = styleErr.Render(" ✗ " + m.errMsg) } else { hint := " tab: rooms · ctrl+n: nets · /join /net /room /react · ctrl+i: invite · ctrl+c: quit" statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint) } view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine) 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) renderLeft(boxH int) string { innerW := sideW - 2 contentH := boxH - 2 sep := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)) var lines []string // Networks section lines = append(lines, styleHeader.Width(innerW).Render("Networks")) lines = append(lines, sep) if len(m.nets) == 0 { lines = append(lines, styleRoom.Width(innerW).Render(" (none)")) } for i, n := range m.nets { label := n.name if i == m.activeNet { lines = append(lines, styleNetActive.Width(innerW).Render("▶ "+label)) } else { lines = append(lines, styleNet.Width(innerW).Render(fmt.Sprintf(" [%d] %s", i+1, label))) } } lines = append(lines, sep) // Rooms section lines = append(lines, styleHeader.Width(innerW).Render("Rooms")) lines = append(lines, sep) n := m.activeNetData() if n == nil { lines = append(lines, styleRoom.Width(innerW).Render(" (no network)")) } else { for i, room := range n.rooms { label := roomLabel(room, n.peers) key := n.id + ":" + room if i == n.activeRoom { lines = append(lines, styleActive.Width(innerW).Render("▶ "+label)) } else { prefix := " " if m.unread[key] { 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))) n := m.activeNetData() if n != nil { if n.localAlias != "" { lines = append(lines, styleSelf.Width(innerW).Render("◉ "+n.localAlias+" (me)")) } for _, pid := range n.peerOrder { alias := n.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() n := m.activeNetData() var roomName string if n != nil { roomName = n.rooms[n.activeRoom] } else { roomName = "general" } var peerMap map[proto.PeerID]string if n != nil { peerMap = n.peers } title := styleTitle.Width(innerW).Render(" " + roomTitle(roomName, peerMap)) 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 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 peers != nil { if a, ok := peers[pid]; ok && a != "" { return "@ " + a } } return "@ " + shortID(pid) } return "#" + room } func roomTitle(room string, peers map[proto.PeerID]string) string { return roomLabel(room, peers) } 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 formatMsgTime(t time.Time) string { now := time.Now() if t.Year() == now.Year() && t.YearDay() == now.YearDay() { return t.Format("15:04") } return t.Format("Jan 2 15:04") } 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 (optional)") joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name") flag.Parse() if *joinInvite != "" { inv, err := invite.Decode(*joinInvite) if err != nil { fmt.Fprintln(os.Stderr, "error: invalid invite:", err) os.Exit(1) } *network = inv.Network } 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) } }