diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go new file mode 100644 index 0000000..c2efaec --- /dev/null +++ b/cmd/chat-cli/api/client.go @@ -0,0 +1,206 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Client wraps HTTP calls to the chat server API. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// NewClient creates a new API client. +func NewClient(baseURL string) *Client { + return &Client{ + BaseURL: baseURL, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) do(method, path string, body interface{}) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, c.BaseURL+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode >= 400 { + return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + + return data, nil +} + +// CreateSession creates a new session on the server. +func (c *Client) CreateSession(nick string) (*SessionResponse, error) { + data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick}) + if err != nil { + return nil, err + } + var resp SessionResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode session: %w", err) + } + c.Token = resp.Token + return &resp, nil +} + +// GetState returns the current user state. +func (c *Client) GetState() (*StateResponse, error) { + data, err := c.do("GET", "/api/v1/state", nil) + if err != nil { + return nil, err + } + var resp StateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + return &resp, nil +} + +// SendMessage sends a message (any IRC command). +func (c *Client) SendMessage(msg *Message) error { + _, err := c.do("POST", "/api/v1/messages", msg) + return err +} + +// PollMessages long-polls for new messages. +func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { + // Use a longer HTTP timeout than the server long-poll timeout. + client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second} + + params := url.Values{} + if afterID != "" { + params.Set("after", afterID) + } + params.Set("timeout", fmt.Sprintf("%d", timeout)) + + path := "/api/v1/messages" + if len(params) > 0 { + path += "?" + params.Encode() + } + + req, err := http.NewRequest("GET", c.BaseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + + // The server may return an array directly or wrapped. + var msgs []Message + if err := json.Unmarshal(data, &msgs); err != nil { + // Try wrapped format. + var wrapped MessagesResponse + if err2 := json.Unmarshal(data, &wrapped); err2 != nil { + return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data)) + } + msgs = wrapped.Messages + } + + return msgs, nil +} + +// JoinChannel joins a channel. +func (c *Client) JoinChannel(channel string) error { + _, err := c.do("POST", "/api/v1/channels/join", map[string]string{"channel": channel}) + return err +} + +// PartChannel leaves a channel. +func (c *Client) PartChannel(channel string) error { + _, err := c.do("DELETE", "/api/v1/channels/"+url.PathEscape(channel), nil) + return err +} + +// ListChannels returns all channels on the server. +func (c *Client) ListChannels() ([]Channel, error) { + data, err := c.do("GET", "/api/v1/channels/all", nil) + if err != nil { + return nil, err + } + var channels []Channel + if err := json.Unmarshal(data, &channels); err != nil { + return nil, err + } + return channels, nil +} + +// GetMembers returns members of a channel. +func (c *Client) GetMembers(channel string) ([]string, error) { + data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil) + if err != nil { + return nil, err + } + var members []string + if err := json.Unmarshal(data, &members); err != nil { + // Try object format. + var obj map[string]interface{} + if err2 := json.Unmarshal(data, &obj); err2 != nil { + return nil, err + } + // Extract member names from whatever format. + return nil, fmt.Errorf("unexpected members format: %s", string(data)) + } + return members, nil +} + +// GetServerInfo returns server info. +func (c *Client) GetServerInfo() (*ServerInfo, error) { + data, err := c.do("GET", "/api/v1/server", nil) + if err != nil { + return nil, err + } + var info ServerInfo + if err := json.Unmarshal(data, &info); err != nil { + return nil, err + } + return &info, nil +} diff --git a/cmd/chat-cli/api/types.go b/cmd/chat-cli/api/types.go new file mode 100644 index 0000000..1655d79 --- /dev/null +++ b/cmd/chat-cli/api/types.go @@ -0,0 +1,83 @@ +package api + +import "time" + +// SessionRequest is the body for POST /api/v1/session. +type SessionRequest struct { + Nick string `json:"nick"` +} + +// SessionResponse is the response from POST /api/v1/session. +type SessionResponse struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + Nick string `json:"nick"` + Token string `json:"token"` +} + +// StateResponse is the response from GET /api/v1/state. +type StateResponse struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + Nick string `json:"nick"` + Channels []string `json:"channels"` +} + +// Message represents a chat message envelope. +type Message struct { + Command string `json:"command"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Params []string `json:"params,omitempty"` + Body interface{} `json:"body,omitempty"` + ID string `json:"id,omitempty"` + TS string `json:"ts,omitempty"` + Meta interface{} `json:"meta,omitempty"` +} + +// BodyLines returns the body as a slice of strings (for text messages). +func (m *Message) BodyLines() []string { + switch v := m.Body.(type) { + case []interface{}: + lines := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + lines = append(lines, s) + } + } + return lines + case []string: + return v + default: + return nil + } +} + +// Channel represents a channel in the list response. +type Channel struct { + Name string `json:"name"` + Topic string `json:"topic"` + Members int `json:"members"` + CreatedAt string `json:"created_at"` +} + +// ServerInfo is the response from GET /api/v1/server. +type ServerInfo struct { + Name string `json:"name"` + MOTD string `json:"motd"` + Version string `json:"version"` +} + +// MessagesResponse wraps polling results. +type MessagesResponse struct { + Messages []Message `json:"messages"` +} + +// ParseTS parses the message timestamp. +func (m *Message) ParseTS() time.Time { + t, err := time.Parse(time.RFC3339Nano, m.TS) + if err != nil { + return time.Now() + } + return t +} diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go new file mode 100644 index 0000000..d0bd2a9 --- /dev/null +++ b/cmd/chat-cli/main.go @@ -0,0 +1,580 @@ +package main + +import ( + "fmt" + "os" + "strings" + "sync" + "time" + + "git.eeqj.de/sneak/chat/cmd/chat-cli/api" +) + +// App holds the application state. +type App struct { + ui *UI + client *api.Client + + mu sync.Mutex + nick string + target string // current target (#channel or nick for DM) + connected bool + lastMsgID string + stopPoll chan struct{} +} + +func main() { + app := &App{ + ui: NewUI(), + nick: "guest", + } + + app.ui.OnInput(app.handleInput) + app.ui.SetStatus(app.nick, "", "disconnected") + + app.ui.AddStatus("Welcome to chat-cli — an IRC-style client") + app.ui.AddStatus("Type [yellow]/connect [white] to begin, or [yellow]/help[white] for commands") + + if err := app.ui.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func (a *App) handleInput(text string) { + if strings.HasPrefix(text, "/") { + a.handleCommand(text) + return + } + + // Plain text → PRIVMSG to current target. + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected. Use /connect ") + return + } + if target == "" { + a.ui.AddStatus("[red]No target. Use /join #channel or /query nick") + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "PRIVMSG", + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err)) + return + } + + // Echo locally. + ts := time.Now().Format("15:04") + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) +} + +func (a *App) handleCommand(text string) { + parts := strings.SplitN(text, " ", 2) + cmd := strings.ToLower(parts[0]) + args := "" + if len(parts) > 1 { + args = parts[1] + } + + switch cmd { + case "/connect": + a.cmdConnect(args) + case "/nick": + a.cmdNick(args) + case "/join": + a.cmdJoin(args) + case "/part": + a.cmdPart(args) + case "/msg": + a.cmdMsg(args) + case "/query": + a.cmdQuery(args) + case "/topic": + a.cmdTopic(args) + case "/names": + a.cmdNames() + case "/list": + a.cmdList() + case "/window", "/w": + a.cmdWindow(args) + case "/quit": + a.cmdQuit() + case "/help": + a.cmdHelp() + default: + a.ui.AddStatus(fmt.Sprintf("[red]Unknown command: %s", cmd)) + } +} + +func (a *App) cmdConnect(serverURL string) { + if serverURL == "" { + a.ui.AddStatus("[red]Usage: /connect ") + return + } + serverURL = strings.TrimRight(serverURL, "/") + + a.ui.AddStatus(fmt.Sprintf("Connecting to %s...", serverURL)) + + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + + client := api.NewClient(serverURL) + resp, err := client.CreateSession(nick) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err)) + return + } + + a.mu.Lock() + a.client = client + a.nick = resp.Nick + a.connected = true + a.lastMsgID = "" + a.mu.Unlock() + + a.ui.AddStatus(fmt.Sprintf("[green]Connected! Nick: %s, Session: %s", resp.Nick, resp.SessionID)) + a.ui.SetStatus(resp.Nick, "", "connected") + + // Start polling. + a.stopPoll = make(chan struct{}) + go a.pollLoop() +} + +func (a *App) cmdNick(nick string) { + if nick == "" { + a.ui.AddStatus("[red]Usage: /nick ") + return + } + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.mu.Lock() + a.nick = nick + a.mu.Unlock() + a.ui.AddStatus(fmt.Sprintf("Nick set to %s (will be used on connect)", nick)) + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "NICK", + Body: []string{nick}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err)) + return + } + + a.mu.Lock() + a.nick = nick + target := a.target + a.mu.Unlock() + a.ui.SetStatus(nick, target, "connected") + a.ui.AddStatus(fmt.Sprintf("Nick changed to %s", nick)) +} + +func (a *App) cmdJoin(channel string) { + if channel == "" { + a.ui.AddStatus("[red]Usage: /join #channel") + return + } + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.JoinChannel(channel) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err)) + return + } + + a.mu.Lock() + a.target = channel + nick := a.nick + a.mu.Unlock() + + a.ui.SwitchToBuffer(channel) + a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Joined %s", channel)) + a.ui.SetStatus(nick, channel, "connected") +} + +func (a *App) cmdPart(channel string) { + a.mu.Lock() + if channel == "" { + channel = a.target + } + connected := a.connected + a.mu.Unlock() + + if channel == "" || !strings.HasPrefix(channel, "#") { + a.ui.AddStatus("[red]No channel to part") + return + } + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.PartChannel(channel) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err)) + return + } + + a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Left %s", channel)) + + a.mu.Lock() + if a.target == channel { + a.target = "" + } + nick := a.nick + a.mu.Unlock() + + a.ui.SwitchBuffer(0) + a.ui.SetStatus(nick, "", "connected") +} + +func (a *App) cmdMsg(args string) { + parts := strings.SplitN(args, " ", 2) + if len(parts) < 2 { + a.ui.AddStatus("[red]Usage: /msg ") + return + } + target, text := parts[0], parts[1] + + a.mu.Lock() + connected := a.connected + nick := a.nick + a.mu.Unlock() + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "PRIVMSG", + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err)) + return + } + + ts := time.Now().Format("15:04") + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) +} + +func (a *App) cmdQuery(nick string) { + if nick == "" { + a.ui.AddStatus("[red]Usage: /query ") + return + } + + a.mu.Lock() + a.target = nick + myNick := a.nick + a.mu.Unlock() + + a.ui.SwitchToBuffer(nick) + a.ui.SetStatus(myNick, nick, "connected") +} + +func (a *App) cmdTopic(args string) { + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + if !strings.HasPrefix(target, "#") { + a.ui.AddStatus("[red]Not in a channel") + return + } + + if args == "" { + // Query topic. + err := a.client.SendMessage(&api.Message{ + Command: "TOPIC", + To: target, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err)) + } + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "TOPIC", + To: target, + Body: []string{args}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Topic set failed: %v", err)) + } +} + +func (a *App) cmdNames() { + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + if !strings.HasPrefix(target, "#") { + a.ui.AddStatus("[red]Not in a channel") + return + } + + members, err := a.client.GetMembers(target) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err)) + return + } + + a.ui.AddLine(target, fmt.Sprintf("[cyan]*** Members of %s: %s", target, strings.Join(members, " "))) +} + +func (a *App) cmdList() { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + channels, err := a.client.ListChannels() + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err)) + return + } + + a.ui.AddStatus("[cyan]*** Channel list:") + for _, ch := range channels { + a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic)) + } + a.ui.AddStatus("[cyan]*** End of channel list") +} + +func (a *App) cmdWindow(args string) { + if args == "" { + a.ui.AddStatus("[red]Usage: /window ") + return + } + n := 0 + fmt.Sscanf(args, "%d", &n) + a.ui.SwitchBuffer(n) + + a.mu.Lock() + if n < a.ui.BufferCount() && n >= 0 { + // Update target to the buffer name. + // Needs to be done carefully. + } + nick := a.nick + a.mu.Unlock() + + // Update target based on buffer. + if n < a.ui.BufferCount() { + buf := a.ui.buffers[n] + if buf.Name != "(status)" { + a.mu.Lock() + a.target = buf.Name + a.mu.Unlock() + a.ui.SetStatus(nick, buf.Name, "connected") + } else { + a.ui.SetStatus(nick, "", "connected") + } + } +} + +func (a *App) cmdQuit() { + a.mu.Lock() + if a.connected && a.client != nil { + _ = a.client.SendMessage(&api.Message{Command: "QUIT"}) + } + if a.stopPoll != nil { + close(a.stopPoll) + } + a.mu.Unlock() + a.ui.Stop() +} + +func (a *App) cmdHelp() { + help := []string{ + "[cyan]*** chat-cli commands:", + " /connect — Connect to server", + " /nick — Change nickname", + " /join #channel — Join channel", + " /part [#chan] — Leave channel", + " /msg — Send DM", + " /query — Open DM window", + " /topic [text] — View/set topic", + " /names — List channel members", + " /list — List channels", + " /window — Switch buffer (Alt+0-9)", + " /quit — Disconnect and exit", + " /help — This help", + " Plain text sends to current target.", + } + for _, line := range help { + a.ui.AddStatus(line) + } +} + +// pollLoop long-polls for messages in the background. +func (a *App) pollLoop() { + for { + select { + case <-a.stopPoll: + return + default: + } + + a.mu.Lock() + client := a.client + lastID := a.lastMsgID + a.mu.Unlock() + + if client == nil { + return + } + + msgs, err := client.PollMessages(lastID, 15) + if err != nil { + // Transient error — retry after delay. + time.Sleep(2 * time.Second) + continue + } + + for _, msg := range msgs { + a.handleServerMessage(&msg) + if msg.ID != "" { + a.mu.Lock() + a.lastMsgID = msg.ID + a.mu.Unlock() + } + } + } +} + +func (a *App) handleServerMessage(msg *api.Message) { + ts := "" + if msg.TS != "" { + t := msg.ParseTS() + ts = t.Local().Format("15:04") + } else { + ts = time.Now().Format("15:04") + } + + a.mu.Lock() + myNick := a.nick + a.mu.Unlock() + + switch msg.Command { + case "PRIVMSG": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if msg.From == myNick { + // Skip our own echoed messages (already displayed locally). + return + } + target := msg.To + if !strings.HasPrefix(target, "#") { + // DM — use sender's nick as buffer name. + target = msg.From + } + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) + + case "JOIN": + target := msg.To + if target != "" { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target)) + } + + case "PART": + target := msg.To + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + if target != "" { + if reason != "" { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason)) + } else { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target)) + } + } + + case "QUIT": + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + if reason != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason)) + } else { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From)) + } + + case "NICK": + lines := msg.BodyLines() + newNick := "" + if len(lines) > 0 { + newNick = lines[0] + } + if msg.From == myNick && newNick != "" { + a.mu.Lock() + a.nick = newNick + target := a.target + a.mu.Unlock() + a.ui.SetStatus(newNick, target, "connected") + } + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick)) + + case "NOTICE": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) + + case "TOPIC": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if msg.To != "" { + a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) + } + + default: + // Numeric replies and other messages → status window. + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if text != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) + } + } +} diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go new file mode 100644 index 0000000..16449f2 --- /dev/null +++ b/cmd/chat-cli/ui.go @@ -0,0 +1,233 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// Buffer holds messages for a channel/DM/status window. +type Buffer struct { + Name string + Lines []string + Unread int +} + +// UI manages the terminal interface. +type UI struct { + app *tview.Application + messages *tview.TextView + statusBar *tview.TextView + input *tview.InputField + layout *tview.Flex + + buffers []*Buffer + currentBuffer int + + onInput func(string) +} + +// NewUI creates the tview-based IRC-like UI. +func NewUI() *UI { + ui := &UI{ + app: tview.NewApplication(), + buffers: []*Buffer{ + {Name: "(status)", Lines: nil}, + }, + } + + // Message area. + ui.messages = tview.NewTextView(). + SetDynamicColors(true). + SetScrollable(true). + SetWordWrap(true). + SetChangedFunc(func() { + ui.app.Draw() + }) + ui.messages.SetBorder(false) + + // Status bar. + ui.statusBar = tview.NewTextView(). + SetDynamicColors(true) + ui.statusBar.SetBackgroundColor(tcell.ColorNavy) + ui.statusBar.SetTextColor(tcell.ColorWhite) + + // Input field. + ui.input = tview.NewInputField(). + SetFieldBackgroundColor(tcell.ColorBlack). + SetFieldTextColor(tcell.ColorWhite) + ui.input.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEnter { + text := ui.input.GetText() + if text == "" { + return + } + ui.input.SetText("") + if ui.onInput != nil { + ui.onInput(text) + } + } + }) + + // Capture Alt+N for window switching. + ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Modifiers()&tcell.ModAlt != 0 { + r := event.Rune() + if r >= '0' && r <= '9' { + idx := int(r - '0') + ui.SwitchBuffer(idx) + return nil + } + } + return event + }) + + // Layout: messages on top, status bar, input at bottom. + ui.layout = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(ui.messages, 0, 1, false). + AddItem(ui.statusBar, 1, 0, false). + AddItem(ui.input, 1, 0, true) + + ui.app.SetRoot(ui.layout, true) + ui.app.SetFocus(ui.input) + + return ui +} + +// Run starts the UI event loop (blocks). +func (ui *UI) Run() error { + return ui.app.Run() +} + +// Stop stops the UI. +func (ui *UI) Stop() { + ui.app.Stop() +} + +// OnInput sets the callback for user input. +func (ui *UI) OnInput(fn func(string)) { + ui.onInput = fn +} + +// AddLine adds a line to the specified buffer. +func (ui *UI) AddLine(bufferName string, line string) { + ui.app.QueueUpdateDraw(func() { + buf := ui.getOrCreateBuffer(bufferName) + buf.Lines = append(buf.Lines, line) + + // Mark unread if not currently viewing this buffer. + if ui.buffers[ui.currentBuffer] != buf { + buf.Unread++ + ui.refreshStatus() + } + + // If viewing this buffer, append to display. + if ui.buffers[ui.currentBuffer] == buf { + fmt.Fprintln(ui.messages, line) + } + }) +} + +// AddStatus adds a line to the status buffer (buffer 0). +func (ui *UI) AddStatus(line string) { + ts := time.Now().Format("15:04") + ui.AddLine("(status)", fmt.Sprintf("[gray]%s[white] %s", ts, line)) +} + +// SwitchBuffer switches to the buffer at index n. +func (ui *UI) SwitchBuffer(n int) { + ui.app.QueueUpdateDraw(func() { + if n < 0 || n >= len(ui.buffers) { + return + } + ui.currentBuffer = n + buf := ui.buffers[n] + buf.Unread = 0 + ui.messages.Clear() + for _, line := range buf.Lines { + fmt.Fprintln(ui.messages, line) + } + ui.messages.ScrollToEnd() + ui.refreshStatus() + }) +} + +// SwitchToBuffer switches to the named buffer, creating it if needed. +func (ui *UI) SwitchToBuffer(name string) { + ui.app.QueueUpdateDraw(func() { + buf := ui.getOrCreateBuffer(name) + for i, b := range ui.buffers { + if b == buf { + ui.currentBuffer = i + break + } + } + buf.Unread = 0 + ui.messages.Clear() + for _, line := range buf.Lines { + fmt.Fprintln(ui.messages, line) + } + ui.messages.ScrollToEnd() + ui.refreshStatus() + }) +} + +// SetStatus updates the status bar text. +func (ui *UI) SetStatus(nick, target, connStatus string) { + ui.app.QueueUpdateDraw(func() { + ui.refreshStatusWith(nick, target, connStatus) + }) +} + +func (ui *UI) refreshStatus() { + // Will be called from the main goroutine via QueueUpdateDraw parent. + // Rebuild status from app state — caller must provide context. +} + +func (ui *UI) refreshStatusWith(nick, target, connStatus string) { + var unreadParts []string + for i, buf := range ui.buffers { + if buf.Unread > 0 { + unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread)) + } + } + unread := "" + if len(unreadParts) > 0 { + unread = " [Act: " + strings.Join(unreadParts, ",") + "]" + } + + bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name) + + ui.statusBar.Clear() + fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s", + connStatus, nick, bufInfo, target, unread) +} + +func (ui *UI) getOrCreateBuffer(name string) *Buffer { + for _, buf := range ui.buffers { + if buf.Name == name { + return buf + } + } + buf := &Buffer{Name: name} + ui.buffers = append(ui.buffers, buf) + return buf +} + +// BufferCount returns the number of buffers. +func (ui *UI) BufferCount() int { + return len(ui.buffers) +} + +// BufferIndex returns the index of a named buffer, or -1. +func (ui *UI) BufferIndex(name string) int { + for i, buf := range ui.buffers { + if buf.Name == name { + return i + } + } + return -1 +} diff --git a/go.mod b/go.mod index 68e8109..b1b1166 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,11 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.13.8 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -30,6 +33,8 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/tview v0.42.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -42,8 +47,9 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.8 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 4d55450..7d3b111 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= +github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= @@ -40,6 +44,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -64,6 +70,10 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 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/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -86,6 +96,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= @@ -100,19 +111,55 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +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= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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-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= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=