Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,14 @@ While in interactive mode, you can use the following keyboard shortcuts:
- `o` - Open the selected event in the Hookdeck dashboard
- `d` - Show detailed request/response information for the selected event (press `d` or `ESC` to close)
- When details view is open: `↑` / `↓` scroll through content, `PgUp` / `PgDown` for page navigation
- Press `C` to copy the complete request, `H` for request headers, or `B` for the request body; off-screen content is included
- `q` - Quit the application (terminal state is restored)
- `Ctrl+C` - Also quits the application

The selected event is indicated by a `>` character at the beginning of the line. All actions (retry, open, details) work on the currently selected event, not just the latest one. These shortcuts are displayed in the status bar at the bottom of the screen.

> **Note:** Copying to the clipboard works out of the box on macOS and Windows. On Linux and other BSD/Unix systems it requires either [`xclip`](https://github.com/astrand/xclip) or [`xsel`](https://github.com/kfish/xsel) to be installed; without one of them the copy shortcuts report an error.

#### Listen to all your connections for a given source

The second param, `source-alias` is used to select a specific source to listen on. By default, the CLI will start listening on all eligible connections for that source.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/BurntSushi/toml v1.6.0
github.com/atotto/clipboard v0.1.4
github.com/briandowns/spinner v1.23.2
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
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/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
Expand Down
150 changes: 118 additions & 32 deletions pkg/listen/tui/model.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
package tui

import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"

"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
)

const (
maxEvents = 1000 // Maximum events to keep in memory (all navigable)
timeLayout = "2006-01-02 15:04:05" // Time format for display
maxEvents = 1000 // Maximum events to keep in memory (all navigable)
timeLayout = "2006-01-02 15:04:05" // Time format for display
detailsInstructions = "[d] Return to event list • [↑↓] Scroll • [PgUp/PgDn] Page • [C] Copy request • [H] Copy headers • [B] Copy body"
copyStatusTimeout = 5 * time.Second // How long the "Copied …" status stays before clearing
)

// EventInfo represents a single event with all its data
Expand Down Expand Up @@ -61,6 +65,11 @@ type Model struct {
showingDetails bool
detailsViewport viewport.Model
detailsContent string
detailsCopy requestCopyContent
detailsCopyState detailsCopyState
detailsCopyLabel string
detailsCopyGen uint64 // Invalidates stale copy-status clear timers
clipboardWrite func(string) error
eventsTitleShown bool // Track if "Events" title has been displayed

// Header state
Expand Down Expand Up @@ -92,12 +101,13 @@ type Config struct {
// NewModel creates a new TUI model
func NewModel(cfg *Config) Model {
return Model{
cfg: cfg,
client: cfg.APIClient,
events: make([]EventInfo, 0),
selectedIndex: -1,
ready: false,
isConnected: false,
cfg: cfg,
client: cfg.APIClient,
events: make([]EventInfo, 0),
selectedIndex: -1,
ready: false,
isConnected: false,
clipboardWrite: clipboard.WriteAll,
}
}

Expand Down Expand Up @@ -277,12 +287,12 @@ func (m *Model) calculateHeaderHeight(header string) int {
return strings.Count(header, "\n") + 1
}

// buildDetailsContent builds the formatted details view for an event
func (m *Model) buildDetailsContent(event *EventInfo) string {
// buildEventDetailsContent builds the event data shown in the details view.
// Navigation instructions are intentionally excluded from the request copy
// content so clipboard output contains only request data.
func (m *Model) buildEventDetailsContent(event *EventInfo) (string, requestCopyContent) {
var content strings.Builder

content.WriteString(faintStyle.Render("[d] Return to event list • [↑↓] Scroll • [PgUp/PgDn] Page"))
content.WriteString("\n\n")
var requestCopy requestCopyContent

// Event metadata - compact single line format
var metadataLine strings.Builder
Expand All @@ -305,28 +315,31 @@ func (m *Model) buildDetailsContent(event *EventInfo) string {

// HTTP request line: METHOD URL
requestURL := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + event.Data.Body.Path
content.WriteString(event.Data.Body.Request.Method + " " + requestURL + "\n\n")
requestCopy.requestLine = event.Data.Body.Request.Method + " " + requestURL
content.WriteString(requestCopy.requestLine + "\n\n")

// Request headers
// Request headers - preserve the order they arrived in
if len(event.Data.Body.Request.Headers) > 0 {
// Parse headers JSON
var headers map[string]string
if err := json.Unmarshal(event.Data.Body.Request.Headers, &headers); err == nil {
for key, value := range headers {
content.WriteString(faintStyle.Render(key+": ") + value + "\n")
if headers, ok := parseOrderedHeaders(event.Data.Body.Request.Headers); ok {
for _, h := range headers {
content.WriteString(faintStyle.Render(h.key+": ") + h.value + "\n")
requestCopy.headers += h.key + ": " + h.value + "\n"
}
} else {
content.WriteString(string(event.Data.Body.Request.Headers) + "\n")
requestCopy.headers = string(event.Data.Body.Request.Headers) + "\n"
content.WriteString(requestCopy.headers)
}
}
requestCopy.headers = strings.TrimSuffix(requestCopy.headers, "\n")
content.WriteString("\n")

// Request body
if event.Data.Body.Request.DataString != "" {
// Try to pretty print JSON
prettyBody := m.prettyPrintJSON(event.Data.Body.Request.DataString)
content.WriteString(prettyBody + "\n")
requestCopy.body = m.prettyPrintJSON(event.Data.Body.Request.DataString)
content.WriteString(requestCopy.body + "\n")
}
requestCopy.buildRequest()
content.WriteString("\n")
}

Expand Down Expand Up @@ -358,25 +371,98 @@ func (m *Model) buildDetailsContent(event *EventInfo) string {
content.WriteString(faintStyle.Render("(No response received yet)") + "\n")
}

return content.String()
return content.String(), requestCopy
}

// setDetailsContent builds the display and request-only clipboard content
// together, including request data outside the visible viewport.
func (m *Model) setDetailsContent(event *EventInfo) {
details, requestCopy := m.buildEventDetailsContent(event)
// The instructions are rendered in the always-visible action bar
// (see renderDetailsView), so they are intentionally not repeated at the
// top of the scrollable content.
m.detailsContent = details
m.detailsCopy = requestCopy
m.detailsCopyState = detailsCopyIdle
m.detailsCopyLabel = ""
}

type requestCopyContent struct {
requestLine string
request string
headers string
body string
}

// prettyPrintJSON attempts to pretty print JSON, returns original if not valid JSON
func (c *requestCopyContent) buildRequest() {
parts := []string{c.requestLine}
if c.headers != "" {
parts = append(parts, c.headers)
}
if c.body != "" {
parts = append(parts, c.body)
}
c.request = strings.Join(parts, "\n\n")
}

// prettyPrintJSON attempts to pretty print JSON, returns original if not valid JSON.
// It uses json.Indent so object key order is preserved exactly as received rather
// than being sorted (which json.Marshal would do).
func (m *Model) prettyPrintJSON(input string) string {
var obj interface{}
if err := json.Unmarshal([]byte(input), &obj); err != nil {
var buf bytes.Buffer
if err := json.Indent(&buf, []byte(input), "", " "); err != nil {
// Not valid JSON, return original
return input
}

// Pretty print with 2-space indentation
pretty, err := json.MarshalIndent(obj, "", " ")
return buf.String()
}

// headerPair is a single request header, retaining its original position.
type headerPair struct {
key string
value string
}

// parseOrderedHeaders decodes a JSON object of string-valued headers while
// preserving the order in which the keys appear. It returns false when the
// input is not a flat object of string values, so the caller can fall back to
// rendering the raw payload.
func parseOrderedHeaders(raw []byte) ([]headerPair, bool) {
dec := json.NewDecoder(bytes.NewReader(raw))

tok, err := dec.Token()
if err != nil {
// Fallback to original
return input
return nil, false
}
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
return nil, false
}

var headers []headerPair
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return nil, false
}
key, ok := keyTok.(string)
if !ok {
return nil, false
}

valTok, err := dec.Token()
if err != nil {
return nil, false
}
value, ok := valTok.(string)
if !ok {
return nil, false
}

headers = append(headers, headerPair{key: key, value: value})
}

return string(pretty)
return headers, true
}

// Messages for Bubble Tea
Expand Down
93 changes: 92 additions & 1 deletion pkg/listen/tui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package tui

import (
"context"
"fmt"
"os/exec"
"runtime"
"time"

"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -79,6 +81,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case openBrowserResultMsg:
// Browser opened, could show notification if needed
return m, nil

case copyDetailsResultMsg:
m.detailsCopyLabel = msg.label
if msg.err != nil {
m.detailsCopyState = detailsCopyFailed
} else {
m.detailsCopyState = detailsCopySucceeded
}
// Schedule the status to clear, tagging the timer with the current
// generation so a later copy supersedes this one.
m.detailsCopyGen++
return m, clearCopyStatusAfter(m.detailsCopyGen)

case clearCopyStatusMsg:
// Ignore a stale timer from a copy that has since been superseded.
if msg.gen == m.detailsCopyGen {
m.detailsCopyState = detailsCopyIdle
m.detailsCopyLabel = ""
}
return m, nil
}

return m, nil
Expand Down Expand Up @@ -135,6 +157,21 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}

case "c", "C":
if m.showingDetails {
return m, m.beginDetailsCopy(m.detailsCopy.request, "request")
}

case "h", "H":
if m.showingDetails {
return m, m.beginDetailsCopy(m.detailsCopy.headers, "request headers")
}

case "b", "B":
if m.showingDetails {
return m, m.beginDetailsCopy(m.detailsCopy.body, "request body")
}

case "r", "R":
// Retry selected event (new attempt will arrive via websocket)
return m, m.retrySelectedEvent()
Expand All @@ -152,7 +189,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Open details view
selectedEvent := m.GetSelectedEvent()
if selectedEvent != nil {
m.detailsContent = m.buildDetailsContent(selectedEvent)
m.setDetailsContent(selectedEvent)
m.showingDetails = true

// Initialize details viewport if not already done
Expand All @@ -174,6 +211,32 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}

// beginDetailsCopy marks a copy as in-progress and returns the command that
// performs the clipboard write. It bumps the generation counter so any pending
// clear timer scheduled by an earlier copy no longer applies.
func (m *Model) beginDetailsCopy(content, label string) tea.Cmd {
m.detailsCopyState = detailsCopyPending
m.detailsCopyLabel = label
m.detailsCopyGen++
return m.copyDetails(content, label)
}

// copyDetails copies the requested portion of the request, including content
// outside the visible viewport, to the system clipboard.
func (m Model) copyDetails(content, label string) tea.Cmd {
write := m.clipboardWrite

return func() tea.Msg {
if write == nil {
return copyDetailsResultMsg{label: label, err: fmt.Errorf("clipboard is unavailable")}
}
if content == "" {
return copyDetailsResultMsg{label: label, err: fmt.Errorf("%s is empty", label)}
}
return copyDetailsResultMsg{label: label, err: write(content)}
}
}

// retrySelectedEvent retries the currently selected event
func (m Model) retrySelectedEvent() tea.Cmd {
selectedEvent := m.GetSelectedEvent()
Expand Down Expand Up @@ -246,3 +309,31 @@ type retryResultMsg struct {
type openBrowserResultMsg struct {
err error
}

type detailsCopyState uint8

const (
detailsCopyIdle detailsCopyState = iota
detailsCopyPending
detailsCopySucceeded
detailsCopyFailed
)

type copyDetailsResultMsg struct {
label string
err error
}

// clearCopyStatusMsg clears the copy status once the timeout elapses. It carries
// the generation it was scheduled for so a superseded timer is ignored.
type clearCopyStatusMsg struct {
gen uint64
}

// clearCopyStatusAfter resets the copy status after copyStatusTimeout, tagging
// the message with the generation that scheduled it.
func clearCopyStatusAfter(gen uint64) tea.Cmd {
return tea.Tick(copyStatusTimeout, func(time.Time) tea.Msg {
return clearCopyStatusMsg{gen: gen}
})
}
Loading
Loading