feat(tui): add HTTP client + daemon data models

- Add Go types matching daemon /queue response (QueueItem, QueueResponse)
- Add Go types for /status response (StatusResponse)
- Add HTTP client with configurable base URL (default http://127.0.0.1:4000)
- Add 1s timeout
- Add FetchQueue() ([]QueueItem, error)
- Add PostSkip() error
- Add PostNext() (string, error)
- Add GetPaneSessionMap() to parse pane_id→session_name map via tmux list-panes -a

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-06-25 00:55:37 -04:00
parent dd0ebbd6ce
commit 3e4f77289f

170
tui/client.go Normal file
View file

@ -0,0 +1,170 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"time"
)
const defaultDaemonURL = "http://127.0.0.1:4000"
var httpClient = &http.Client{Timeout: 1 * time.Second}
func daemonURL() string {
if u := os.Getenv("TRAILBOSS_URL"); u != "" {
return u
}
return defaultDaemonURL
}
// QueueItem represents a single stuck pane in the queue.
// Matches the daemon's /queue response JOIN of queue + sessions.
type QueueItem struct {
ID int `json:"id"`
SessionID string `json:"session_id"`
PaneID string `json:"pane_id"`
CWD string `json:"cwd"`
Reason string `json:"reason"`
LastMessage *string `json:"last_message"` // nullable
StuckAt int64 `json:"stuck_at"` // epoch ms
SkipCooldownUntil *int64 `json:"skip_cooldown_until"` // nullable, epoch ms
}
// QueueResponse is the /queue endpoint response.
type QueueResponse struct {
Items []QueueItem `json:"items"`
Count int `json:"count"`
}
// StatusResponse is the /status endpoint response.
// Matches daemon: { "status": "ok", "stuckCount": N }
type StatusResponse struct {
Status string `json:"status"`
StuckCount int `json:"stuckCount"`
}
// NextResponse is the /next and /skip endpoint response.
// Matches daemon: { "paneId": "...", "sessionId": "...", "reason": null | "..." }
type NextResponse struct {
PaneID *string `json:"paneId"` // null if queue empty
SessionID *string `json:"sessionId"` // null if queue empty
Reason *string `json:"reason"` // null on success, error string if empty
}
// FetchQueue fetches the current queue from the daemon and returns the items.
func FetchQueue() ([]QueueItem, error) {
resp, err := httpClient.Get(daemonURL() + "/queue")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var q QueueResponse
if err := json.NewDecoder(resp.Body).Decode(&q); err != nil {
return nil, err
}
return q.Items, nil
}
// FetchStatus fetches the daemon status.
func FetchStatus() (StatusResponse, error) {
resp, err := httpClient.Get(daemonURL() + "/status")
if err != nil {
return StatusResponse{}, err
}
defer resp.Body.Close()
var s StatusResponse
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return StatusResponse{}, err
}
return s, nil
}
// PostSkip tells the daemon to skip the current queue head.
func PostSkip() error {
resp, err := httpClient.Post(daemonURL()+"/skip", "application/json", nil)
if err != nil {
return err
}
defer resp.Body.Close()
// Drain the response body but don't use it
_ = json.NewDecoder(resp.Body).Decode(&NextResponse{})
return nil
}
// PostNext fetches the head pane_id from the daemon.
// Returns the pane ID string, or empty string if queue is empty (along with any error).
func PostNext() (string, error) {
resp, err := httpClient.Get(daemonURL() + "/next")
if err != nil {
return "", err
}
defer resp.Body.Close()
var nr NextResponse
if err := json.NewDecoder(resp.Body).Decode(&nr); err != nil {
return "", err
}
// If paneId is null or reason indicates an error, return empty string
if nr.PaneID == nil || nr.Reason != nil && *nr.Reason != "" {
return "", nil
}
return *nr.PaneID, nil
}
// GetPaneSessionMap runs tmux list-panes and returns a map of pane_id → session_name.
func GetPaneSessionMap() map[string]string {
out, err := exec.Command("tmux", "list-panes", "-a", "-F", "#{pane_id}:#{session_name}").Output()
if err != nil {
return map[string]string{}
}
m := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
m[parts[0]] = parts[1]
}
}
return m
}
// WritePreviewTarget writes paneID to /tmp/trailboss-preview-target so the
// preview pane below the TUI mirrors that session.
func WritePreviewTarget(paneID string) {
_ = os.WriteFile("/tmp/trailboss-preview-target", []byte(paneID), 0644)
}
// JumpToPane switches the tmux client to the given pane, recording the origin.
func JumpToPane(paneID string) error {
// Resolve session name for the pane.
out, err := exec.Command("tmux", "display", "-p", "-t", paneID, "#{session_name}").Output()
if err != nil {
return fmt.Errorf("resolving session for pane %s: %w", paneID, err)
}
sessionName := strings.TrimSpace(string(out))
if sessionName == "" {
return fmt.Errorf("no session found for pane %s", paneID)
}
// Write origin before switching so prefix+B can return here.
if err := os.WriteFile("/tmp/trailboss-origin", []byte(paneID), 0644); err != nil {
// Non-fatal — log and continue.
_ = err
}
// Switch client to the target pane.
cmd := exec.Command("tmux",
"switch-client", "-t", sessionName, ";",
"select-window", "-t", paneID, ";",
"select-pane", "-t", paneID,
)
return cmd.Run()
}