From 714ac15816bc2fc49991660f2d10ee9bd2af2b99 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Sun, 21 Jun 2026 18:30:56 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 37 +++- cmd/tui/main.go | 566 ++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 24 +- go.sum | 75 +++++++ test-tui.sh | 134 ++++++++++++ 6 files changed, 833 insertions(+), 4 deletions(-) create mode 100644 cmd/tui/main.go create mode 100755 test-tui.sh diff --git a/.gitignore b/.gitignore index 5e70468..ac6dcbb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ .env .DS_Store *.swp +tui diff --git a/README.md b/README.md index dadf5fe..fb596c7 100644 --- a/README.md +++ b/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 - ���── 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:`) - [ ] **Native UI** — web frontend with native packaging (Tauri-style) diff --git a/cmd/tui/main.go b/cmd/tui/main.go new file mode 100644 index 0000000..da2d395 --- /dev/null +++ b/cmd/tui/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod index a5343ae..dc26508 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 91c2593..858c044 100644 --- a/go.sum +++ b/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= diff --git a/test-tui.sh b/test-tui.sh new file mode 100755 index 0000000..ed0e0b0 --- /dev/null +++ b/test-tui.sh @@ -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"