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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@
|
||||
.env
|
||||
.DS_Store
|
||||
*.swp
|
||||
tui
|
||||
|
||||
37
README.md
37
README.md
@@ -9,11 +9,12 @@ friend-to-friend encrypted mesh networking with chat and file sharing. Written i
|
||||
waste-go/
|
||||
├── cmd/
|
||||
│ ├── daemon/ The peer process — run one on each friend's machine
|
||||
│ └── anchor/ WebSocket signaling server — run this on your Hetzner VPS
|
||||
│ ├── anchor/ WebSocket signaling server — run this on your Hetzner VPS
|
||||
│ └── tui/ Bubble Tea terminal UI (connects to a running daemon)
|
||||
└── internal/
|
||||
├── proto/ All wire types (shared by daemon and anchor)
|
||||
├── crypto/ Ed25519 identity, nacl/box signaling, ChaCha20-Poly1305
|
||||
<EFBFBD><EFBFBD><EFBFBD>── mesh/ Connected peer state + DataChannel helpers
|
||||
├── mesh/ Connected peer state + DataChannel helpers
|
||||
├── anchor/ Anchor client — WebRTC signaling via the anchor server
|
||||
└── ipc/ Local JSON API (UI talks to daemon here, port 17337)
|
||||
```
|
||||
@@ -124,6 +125,36 @@ Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
|
||||
> Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files
|
||||
> on disk are unaffected — only the over-the-wire representation changed from base64url.
|
||||
|
||||
## Terminal UI
|
||||
|
||||
Start the daemon first (see Getting started above), then:
|
||||
|
||||
```bash
|
||||
go run ./cmd/tui -network friends
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `-network` | *(required)* | Network name to join on startup |
|
||||
| `-ipc` | `17337` | Daemon IPC port |
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
╭─ Rooms ──────╮╭─── #general ────────────────╮╭─ Peers ──────╮
|
||||
│ ▶ #general ││ 15:04 alice hey everyone ││ ◉ alice (me) │
|
||||
│ @ bob ││ 15:04 bob hi alice! ││ ● bob │
|
||||
│ ││ 15:05 charlie the mesh works ││ ● charlie │
|
||||
╰──────────────╯╰─────────────────────────────╯╰──────────────╯
|
||||
╭─────────────────────────────────────────────────────────────╮
|
||||
│ Type a message… │
|
||||
╰─────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
**Key bindings:** `Tab` / `Shift+Tab` — switch rooms · `PgUp` / `PgDn` — scroll · `Enter` — send · `Ctrl+C` — quit
|
||||
|
||||
## Testing
|
||||
|
||||
A self-contained test script boots anchor + three peers, joins them to a named network, exchanges group messages and DMs, and verifies SQLite persistence:
|
||||
@@ -150,6 +181,6 @@ SELECT peer_id, alias, last_seen FROM peers;
|
||||
- [x] **Anchor client** (`internal/anchor`) — offer/answer/candidate lifecycle, `nacl/box` sealing
|
||||
- [x] **IPC updates** — `join_network`/`leave_network`; `session_ready` event; DMs via `to` field
|
||||
- [x] **Message persistence** — SQLite (`internal/store`); messages and peer alias cache
|
||||
- [ ] **TUI** — Bubble Tea terminal UI consuming the IPC port
|
||||
- [x] **TUI** — Bubble Tea terminal UI (`cmd/tui`); three-pane layout with room switching and DMs
|
||||
- [ ] **File transfer** — chunked binary DataChannel (`f:<xid>`)
|
||||
- [ ] **Native UI** — web frontend with native packaging (Tauri-style)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
24
go.mod
24
go.mod
@@ -7,13 +7,33 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
golang.org/x/crypto v0.24.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pion/datachannel v1.5.8 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
@@ -32,13 +52,15 @@ require (
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.53.0 // indirect
|
||||
)
|
||||
|
||||
75
go.sum
75
go.sum
@@ -1,21 +1,63 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||
@@ -64,6 +106,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -76,6 +120,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@@ -86,6 +132,8 @@ golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -99,9 +147,12 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -127,10 +178,14 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
@@ -138,13 +193,33 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
|
||||
134
test-tui.sh
Executable file
134
test-tui.sh
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-tui.sh — spin up anchor + 3 peers, then launch the TUI as alice.
|
||||
# Bob and Charlie send periodic messages so the UI updates live.
|
||||
# Usage: ./test-tui.sh
|
||||
# Requires: go, nc, jq
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ANCHOR_PORT=19339
|
||||
ALICE_IPC=19337
|
||||
BOB_IPC=19340
|
||||
CHARLIE_IPC=19343
|
||||
NETWORK_NAME="friends"
|
||||
DATA_ROOT="/tmp/waste-tui-test"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; DIM='\033[2m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
rm -rf "$DATA_ROOT"
|
||||
mkdir -p "$DATA_ROOT"
|
||||
|
||||
PIDS=()
|
||||
cleanup() {
|
||||
for pid in "${PIDS[@]:-}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
wait 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
wait_port() {
|
||||
local port="$1" n=0
|
||||
while ! nc -z 127.0.0.1 "$port" 2>/dev/null; do
|
||||
sleep 0.1
|
||||
n=$(( n + 1 ))
|
||||
[ "$n" -gt 80 ] && { echo "timeout waiting for :$port" >&2; exit 1; }
|
||||
done
|
||||
}
|
||||
|
||||
ipc() { echo "$2" | nc -q 0 127.0.0.1 "$1" >/dev/null 2>&1 || true; }
|
||||
|
||||
peer_field() {
|
||||
local port="$1" expr="$2" result="" n=0
|
||||
while [ -z "$result" ] || [ "$result" = "null" ]; do
|
||||
result=$(echo '{"type":"get_state"}' \
|
||||
| timeout 2 nc 127.0.0.1 "$port" 2>/dev/null \
|
||||
| grep '"type":"state_snapshot"' | head -1 \
|
||||
| jq -r "$expr" 2>/dev/null || true)
|
||||
n=$(( n + 1 ))
|
||||
[ "$n" -ge 15 ] && break
|
||||
[ -z "$result" ] || [ "$result" = "null" ] && sleep 0.3
|
||||
done
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}waste-go TUI test${RESET}"
|
||||
echo -e "${DIM}network: ${BOLD}${NETWORK_NAME}${RESET}"
|
||||
echo -e "${DIM}data: ${DATA_ROOT}${RESET}"
|
||||
echo ""
|
||||
|
||||
echo -e "${DIM}building binaries…${RESET}"
|
||||
go build -o "$DATA_ROOT/bin/waste-anchor" ./cmd/anchor
|
||||
go build -o "$DATA_ROOT/bin/waste-daemon" ./cmd/daemon
|
||||
go build -o "$DATA_ROOT/bin/waste-tui" ./cmd/tui
|
||||
echo -e "${GREEN}✓ built${RESET}"
|
||||
echo ""
|
||||
|
||||
# Anchor
|
||||
"$DATA_ROOT/bin/waste-anchor" -bind "127.0.0.1:${ANCHOR_PORT}" \
|
||||
2>/dev/null &
|
||||
PIDS+=($!)
|
||||
wait_port "$ANCHOR_PORT"
|
||||
|
||||
ANCHOR_URL="ws://127.0.0.1:${ANCHOR_PORT}/ws"
|
||||
|
||||
# Daemons
|
||||
"$DATA_ROOT/bin/waste-daemon" -alias alice -data-dir "$DATA_ROOT/alice" \
|
||||
-ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" 2>/dev/null &
|
||||
PIDS+=($!)
|
||||
|
||||
"$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \
|
||||
-ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" 2>/dev/null &
|
||||
PIDS+=($!)
|
||||
|
||||
"$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \
|
||||
-ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" 2>/dev/null &
|
||||
PIDS+=($!)
|
||||
|
||||
wait_port "$ALICE_IPC"
|
||||
wait_port "$BOB_IPC"
|
||||
wait_port "$CHARLIE_IPC"
|
||||
|
||||
# Resolve peer IDs
|
||||
ALICE_ID=$(peer_field "$ALICE_IPC" '.local_peer.id')
|
||||
BOB_ID=$(peer_field "$BOB_IPC" '.local_peer.id')
|
||||
CHARLIE_ID=$(peer_field "$CHARLIE_IPC" '.local_peer.id')
|
||||
|
||||
# Join all three
|
||||
JOIN=$(printf '{"type":"join_network","network_name":"%s"}' "$NETWORK_NAME")
|
||||
ipc "$ALICE_IPC" "$JOIN"
|
||||
sleep 0.2
|
||||
ipc "$BOB_IPC" "$JOIN"
|
||||
sleep 0.2
|
||||
ipc "$CHARLIE_IPC" "$JOIN"
|
||||
|
||||
echo -e "${DIM}waiting for WebRTC DataChannels (6s)…${RESET}"
|
||||
sleep 6
|
||||
|
||||
# Bob and Charlie send a few messages as background noise, then loop
|
||||
(
|
||||
set +e
|
||||
while true; do
|
||||
ipc "$BOB_IPC" '{"type":"send_message","room":"general","body":"bob: still here!"}'
|
||||
sleep 5
|
||||
ipc "$CHARLIE_IPC" '{"type":"send_message","room":"general","body":"charlie: mesh is alive"}'
|
||||
sleep 5
|
||||
# Charlie DMs alice
|
||||
ipc "$CHARLIE_IPC" "$(jq -cn \
|
||||
--arg to "$ALICE_ID" \
|
||||
--arg room "dm:$ALICE_ID" \
|
||||
--arg body "charlie whispering to alice…" \
|
||||
'{"type":"send_message","room":$room,"body":$body,"to":$to}')"
|
||||
sleep 10
|
||||
done
|
||||
) &
|
||||
PIDS+=($!)
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Launching TUI as alice${RESET} — ${DIM}ctrl+c to quit${RESET}"
|
||||
echo ""
|
||||
|
||||
# Run the TUI in the foreground (it takes over the terminal with alt-screen)
|
||||
"$DATA_ROOT/bin/waste-tui" -ipc "$ALICE_IPC" -network "$NETWORK_NAME"
|
||||
Reference in New Issue
Block a user