Phase 5: TUI client (Bubbletea)

- cmd/tui/main.go: entry point with --server, --token, --version flags
- cmd/tui/config.go: config from ~/.config/dumpster/config.yaml or env vars
- cmd/tui/auth.go: login flow, session cookie management
- cmd/tui/api.go: HTTP client for servers, channels, messages, voice
- cmd/tui/model.go: root bubbletea model with state management
- cmd/tui/theme.go: Gruvbox dark lipgloss styles
- cmd/tui/servers.go: server bar with [D] style initials
- cmd/tui/channels.go: channel list with #text and 🔊 voice
- cmd/tui/chat.go: IRC-style message viewport with colored usernames
- cmd/tui/input.go: message input with > _ prompt
- cmd/tui/members.go: member list with ●/◐/○ status
- cmd/tui/ws.go: WebSocket client for real-time events
- cmd/tui/voice.go: voice state tracking and mute toggle
- cmd/tui/keys.go: vim-style keybindings (j/k, g/G, Tab, /, etc)
- cmd/tui/views.go: multi-pane layout rendering
- Makefile: added build-tui and run-tui targets
- go.mod/go.sum: added charmbracelet dependencies
This commit is contained in:
2026-06-28 17:46:19 -04:00
parent 297cc47631
commit 2a6b3830d5
18 changed files with 2288 additions and 17 deletions
+562
View File
@@ -0,0 +1,562 @@
package main
import (
"encoding/json"
"log"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
type focusArea int
const (
focusServers focusArea = iota
focusChannels
focusChat
focusInput
focusMembers
)
type model struct {
// Layout
width int
height int
// Data
user UserProfile
servers []Server
channels []Channel
messages []Message
members []MemberEntryData
// Navigation indices
currentServer int
currentChannel int // flat index in the grouped channel view
hoverServer int
hoverChannel int
// Components
viewport viewport.Model
input textinput.Model
// State
focus focusArea
showMembers bool
showHelp bool
loading bool
err string
statusMsg string
typing string // username currently typing
// Networking
api *APIClient
ws *WSConn
// Voice
voice VoiceState
// Channel ID for the currently displayed channel (to match WS events)
currentChannelID string
}
type initMsg struct{}
type serversLoadedMsg []Server
type channelsLoadedMsg []Channel
type messagesLoadedMsg []Message
type messageSentMsg struct{ msg Message }
type sendErrMsg struct{ err error }
type connectWSMsg struct {
ws *WSConn
token string
}
type channelLoadedData struct {
channels []Channel
idx int
}
func initialModel(api *APIClient) model {
vp := viewport.New(80, 20)
vp.SetContent(HelpStyle.Render(" Loading..."))
input := NewInput()
return model{
api: api,
input: input,
viewport: vp,
focus: focusServers,
loading: true,
showMembers: true,
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(
m.loadInitData(),
)
}
func (m model) loadInitData() tea.Cmd {
return func() tea.Msg {
// Load user
user, err := m.api.GetMe()
if err != nil {
log.Printf("loadInitData: GetMe: %v", err)
} else {
m.user = *user
}
// Load servers
servers, err := m.api.GetServers()
if err != nil {
return sendErrMsg{err}
}
return serversLoadedMsg(servers)
}
}
func (m model) loadChannels(serverID string) tea.Cmd {
return func() tea.Msg {
channels, err := m.api.GetChannels(serverID)
if err != nil {
return sendErrMsg{err}
}
return channelsLoadedMsg(channels)
}
}
func (m model) loadMessages(channelID string) tea.Cmd {
return func() tea.Msg {
messages, err := m.api.GetMessages(channelID, 50, "")
if err != nil {
return sendErrMsg{err}
}
return messagesLoadedMsg(messages)
}
}
func (m model) connectWS() tea.Cmd {
return func() tea.Msg {
ws, err := ConnectWS(m.api.BaseURL, m.api.SessionToken)
if err != nil {
return sendErrMsg{err}
}
ws.StartPing()
return connectWSMsg{ws: ws}
}
}
func (m model) sendMessage() tea.Cmd {
content := strings.TrimSpace(m.input.Value())
if content == "" || m.currentChannelID == "" {
return nil
}
channelID := m.currentChannelID
m.input.SetValue("")
return func() tea.Msg {
msg, err := m.api.SendMessage(channelID, content)
if err != nil {
return sendErrMsg{err}
}
return messageSentMsg{msg: *msg}
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateLayout()
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
case serversLoadedMsg:
m.servers = []Server(msg)
m.loading = false
if len(m.servers) > 0 {
m.currentServer = 0
cmds = append(cmds, m.loadChannels(m.servers[0].ID))
// Connect websocket
cmds = append(cmds, m.connectWS())
// Fetch user
cmds = append(cmds, func() tea.Msg {
user, err := m.api.GetMe()
if err != nil {
return nil
}
return *user
})
}
return m, tea.Batch(cmds...)
case channelsLoadedMsg:
m.channels = []Channel(msg)
if len(m.channels) > 0 {
m.currentChannel = 0
ch := ChannelByFlatIndex(m.channels, 0)
if ch != nil {
m.currentChannelID = ch.ID
return m, m.loadMessages(ch.ID)
}
}
return m, nil
case messagesLoadedMsg:
m.messages = []Message(msg)
// Render and scroll to bottom
content := RenderMessages(m.messages, m.viewport.Width, m.user.Username)
m.viewport.SetContent(content)
m.viewport.GotoBottom()
return m, nil
case messageSentMsg:
// Don't add the message here; it will come back via WebSocket
return m, nil
case UserProfile:
m.user = msg
return m, nil
case connectWSMsg:
m.ws = msg.ws
// Start listening for WS events
return m, m.ws.Listen()
case wsEventMsg:
return m.handleWSEvent(msg)
case wsErrMsg:
// Reconnect on error
m.statusMsg = "Disconnected. Reconnecting..."
return m, m.connectWS()
case sendErrMsg:
m.err = msg.err.Error()
return m, nil
}
// Pass to input if focused
if m.focus == focusInput {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Global keys
switch msg.String() {
case "ctrl+c":
if m.ws != nil {
m.ws.Close()
}
return m, tea.Quit
}
// When input is focused, handle input-specific keys
if m.focus == focusInput {
switch msg.String() {
case "enter":
return m, m.sendMessage()
case "esc":
m.focus = focusChat
m.input.Blur()
return m, nil
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
// Navigation keys when not in input
switch msg.String() {
case "q":
if m.ws != nil {
m.ws.Close()
}
return m, tea.Quit
case "?":
m.showHelp = !m.showHelp
return m, nil
case "tab":
m.cycleFocus(1)
return m, nil
case "shift+tab":
m.cycleFocus(-1)
return m, nil
case "i":
if m.focus != focusInput {
m.focus = focusInput
m.input.Focus()
return m, nil
}
return m, nil
case "m":
if m.voice.Connected {
m.voice.ToggleMute()
m.statusMsg = muteStatus(m.voice)
} else {
m.showMembers = !m.showMembers
}
return m, nil
case "j", "down":
return m, m.navigateDown()
case "k", "up":
return m, m.navigateUp()
case "g":
return m, m.navigateTop()
case "G":
return m, m.navigateBottom()
case "enter":
if m.focus == focusServers {
m.currentServer = m.hoverServer
if m.currentServer < len(m.servers) {
return m, m.loadChannels(m.servers[m.currentServer].ID)
}
} else if m.focus == focusChannels {
m.currentChannel = m.hoverChannel
ch := ChannelByFlatIndex(m.channels, m.currentChannel)
if ch != nil {
m.currentChannelID = ch.ID
return m, m.loadMessages(ch.ID)
}
}
return m, nil
}
return m, nil
}
func (m model) handleWSEvent(event wsEventMsg) (tea.Model, tea.Cmd) {
switch event.Type {
case "MESSAGE_CREATE":
msg, err := parseWSEventMessage(event.Data)
if err == nil && msg.ChannelID == m.currentChannelID {
m.messages = append(m.messages, *msg)
content := RenderMessages(m.messages, m.viewport.Width, m.user.Username)
m.viewport.SetContent(content)
m.viewport.GotoBottom()
}
return m, m.ws.Listen()
case "MESSAGE_UPDATE":
msg, err := parseWSEventMessage(event.Data)
if err == nil {
for i, existing := range m.messages {
if existing.ID == msg.ID {
m.messages[i] = *msg
break
}
}
if msg.ChannelID == m.currentChannelID {
content := RenderMessages(m.messages, m.viewport.Width, m.user.Username)
m.viewport.SetContent(content)
}
}
return m, m.ws.Listen()
case "MESSAGE_DELETE":
id, channelID := parseWSDeleteData(event.Data)
if channelID == m.currentChannelID {
for i, existing := range m.messages {
if existing.ID == id {
m.messages = append(m.messages[:i], m.messages[i+1:]...)
break
}
}
content := RenderMessages(m.messages, m.viewport.Width, m.user.Username)
m.viewport.SetContent(content)
}
return m, m.ws.Listen()
case "TYPING_START":
username, channelID := parseWSTyping(event.Data)
if channelID == m.currentChannelID && username != m.user.Username {
m.typing = username
// Clear after a brief moment would need a tick; for MVP just show it
}
return m, m.ws.Listen()
case "VOICE_JOIN":
var data wsVoiceData
if err := json.Unmarshal(event.Data, &data); err == nil {
m.voice.Participants = append(m.voice.Participants, VoiceParticipant{
UserID: data.UserID,
Username: data.Username,
State: "connected",
})
}
return m, m.ws.Listen()
case "VOICE_LEAVE":
var data wsVoiceData
if err := json.Unmarshal(event.Data, &data); err == nil {
for i, p := range m.voice.Participants {
if p.UserID == data.UserID {
m.voice.Participants = append(m.voice.Participants[:i], m.voice.Participants[i+1:]...)
break
}
}
}
return m, m.ws.Listen()
default:
return m, m.ws.Listen()
}
}
func (m *model) cycleFocus(direction int) {
areas := []focusArea{focusServers, focusChannels, focusChat}
if m.showMembers {
areas = append(areas, focusMembers)
}
current := 0
for i, a := range areas {
if a == m.focus {
current = i
break
}
}
next := (current + direction + len(areas)) % len(areas)
m.focus = areas[next]
}
func (m *model) navigateDown() tea.Cmd {
switch m.focus {
case focusServers:
if m.hoverServer < len(m.servers)-1 {
m.hoverServer++
}
case focusChannels:
if m.hoverChannel < ChannelCount(m.channels)-1 {
m.hoverChannel++
}
case focusChat:
m.viewport.ScrollDown(1)
case focusMembers:
// no-op for MVP
}
return nil
}
func (m *model) navigateUp() tea.Cmd {
switch m.focus {
case focusServers:
if m.hoverServer > 0 {
m.hoverServer--
}
case focusChannels:
if m.hoverChannel > 0 {
m.hoverChannel--
}
case focusChat:
m.viewport.ScrollUp(1)
case focusMembers:
// no-op for MVP
}
return nil
}
func (m *model) navigateTop() tea.Cmd {
switch m.focus {
case focusServers:
m.hoverServer = 0
case focusChannels:
m.hoverChannel = 0
case focusChat:
m.viewport.GotoTop()
}
return nil
}
func (m *model) navigateBottom() tea.Cmd {
switch m.focus {
case focusServers:
m.hoverServer = len(m.servers) - 1
case focusChannels:
m.hoverChannel = ChannelCount(m.channels) - 1
case focusChat:
m.viewport.GotoBottom()
}
return nil
}
func (m *model) updateLayout() {
// Server bar: 6 wide
// Channel list: 22 wide
// Member list: 20 wide (optional)
// Chat: remainder
// Input: 3 lines at bottom
// Status bar: 1 line
serverBarWidth := 8
channelWidth := 24
memberWidth := 20
inputHeight := 3
statusHeight := 1
chatHeight := m.height - inputHeight - statusHeight - 2
if chatHeight < 5 {
chatHeight = 5
}
chatWidth := m.width - serverBarWidth - channelWidth - 2
if m.showMembers {
chatWidth -= memberWidth
}
if chatWidth < 20 {
chatWidth = 20
}
m.viewport.Width = chatWidth - 2
m.viewport.Height = chatHeight - 1
m.input.Width = m.width - serverBarWidth - channelWidth - 6
}
func muteStatus(v VoiceState) string {
if v.Muted {
return "🔇 Muted"
}
return "🔊 Unmuted"
}
func (m model) View() string {
if m.width == 0 || m.height == 0 {
return "Loading..."
}
if m.loading {
return TitleStyle.Width(m.width).Height(m.height).Render(
"🗑 Dumpster Chat\n\n" + HelpStyle.Render("Loading..."))
}
return renderLayout(m)
}