From be53a0f9967bf04c00332e5f7b970e7b03be5465 Mon Sep 17 00:00:00 2001 From: soy-chrislo Date: Sat, 4 Jul 2026 23:09:45 -0500 Subject: [PATCH] Add clipboard copy for log message and full entry (ctrl+y / y) In the log details modal, "y" (or "alt+y") copies just the log message, and "ctrl+y" copies the full entry as plain text (severity, timestamps, attributes, and the AI analysis if one has been run), suitable for pasting into Slack or an AI chat. This follows the design agreed on in the issue thread (atotto/clipboard, ctrl-y for the full log, y/alt-y for the message). Copying the AI analysis is folded into ctrl+y instead of a separate binding, since the analysis result already lives in the same info pane as the log details - happy to split it into its own key if maintainers prefer. Fixes #9 --- go.mod | 2 +- internal/tui/clipboard.go | 68 ++++++++++++++++++++++ internal/tui/clipboard_test.go | 101 +++++++++++++++++++++++++++++++++ internal/tui/modal_base.go | 12 +++- internal/tui/modal_help.go | 2 + internal/tui/model.go | 4 ++ internal/tui/navigation.go | 16 ++++++ internal/tui/update.go | 5 ++ 8 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 internal/tui/clipboard.go create mode 100644 internal/tui/clipboard_test.go diff --git a/go.mod b/go.mod index e591ca8..f5bd887 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/NimbleMarkets/ntcharts v0.3.1 + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/glamour v1.0.0 @@ -25,7 +26,6 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go new file mode 100644 index 0000000..2114246 --- /dev/null +++ b/internal/tui/clipboard.go @@ -0,0 +1,68 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/atotto/clipboard" +) + +// copyFeedbackDuration is how long a "Copied ..." status message stays +// visible in the modal status bar before it's cleared on the next tick. +const copyFeedbackDuration = 2 * time.Second + +// copyToClipboard writes text to the OS clipboard. +func copyToClipboard(text string) error { + return clipboard.WriteAll(text) +} + +// setCopyFeedback records the result of a copy action so it can be +// rendered as a transient message in the modal status bar (see +// renderModalStatusBar). It self-expires after copyFeedbackDuration, +// cleared by the TickMsg handler in update.go. +func (m *DashboardModel) setCopyFeedback(err error, what string) { + if err != nil { + m.copyFeedback = fmt.Sprintf("Copy failed: %v", err) + } else { + m.copyFeedback = "Copied " + what + " to clipboard" + } + m.copyFeedbackExpiry = time.Now().Add(copyFeedbackDuration) +} + +// formatLogEntryForClipboard renders a log entry as plain text (no ANSI/ +// lipgloss styling), suitable for pasting outside the terminal — into an +// AI chat, a Slack message, or a bug report. Mirrors the same fields and +// ordering as formatLogDetails/formatAttributesTable (tables.go) so the +// copied text matches what's on screen, minus the styling. +func (m *DashboardModel) formatLogEntryForClipboard(entry LogEntry) string { + var b strings.Builder + + fmt.Fprintf(&b, "Received: %s\n", entry.Timestamp.Format("2006-01-02 15:04:05.000")) + if !entry.OrigTimestamp.IsZero() { + fmt.Fprintf(&b, "Log Time: %s\n", entry.OrigTimestamp.Format("2006-01-02 15:04:05.000")) + } + fmt.Fprintf(&b, "Severity: %s\n", entry.Severity) + fmt.Fprintf(&b, "Message: %s\n", entry.Message) + + if len(entry.Attributes) > 0 { + b.WriteString("\nAttributes:\n") + keys := make([]string, 0, len(entry.Attributes)) + for k := range entry.Attributes { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, " %s: %s\n", k, entry.Attributes[k]) + } + } + + if m.aiAnalysisResult != "" && m.aiAnalysisResult != "Analyzing..." { + b.WriteString("\nAI Analysis:\n") + b.WriteString(m.aiAnalysisResult) + b.WriteString("\n") + } + + return b.String() +} diff --git a/internal/tui/clipboard_test.go b/internal/tui/clipboard_test.go new file mode 100644 index 0000000..bb9c4c3 --- /dev/null +++ b/internal/tui/clipboard_test.go @@ -0,0 +1,101 @@ +package tui + +import ( + "strings" + "testing" + "time" +) + +func TestFormatLogEntryForClipboard(t *testing.T) { + origTs := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + entry := LogEntry{ + Timestamp: time.Date(2026, 1, 2, 3, 4, 6, 0, time.UTC), + OrigTimestamp: origTs, + Severity: "ERROR", + Message: "connection refused", + Attributes: map[string]string{ + "service.name": "checkout", + "host.name": "node-1", + }, + } + + m := &DashboardModel{} + got := m.formatLogEntryForClipboard(entry) + + for _, want := range []string{ + "Severity: ERROR", + "Message: connection refused", + "Log Time: 2026-01-02 03:04:05.000", + "Attributes:", + " host.name: node-1", + " service.name: checkout", + } { + if !strings.Contains(got, want) { + t.Errorf("formatLogEntryForClipboard() missing %q, got:\n%s", want, got) + } + } + + // Attributes must be sorted alphabetically, matching formatAttributesTable. + hostIdx := strings.Index(got, "host.name") + serviceIdx := strings.Index(got, "service.name") + if hostIdx == -1 || serviceIdx == -1 || hostIdx > serviceIdx { + t.Errorf("expected attributes sorted alphabetically (host.name before service.name), got:\n%s", got) + } +} + +func TestFormatLogEntryForClipboard_NoAttributesNoAIAnalysis(t *testing.T) { + entry := LogEntry{ + Timestamp: time.Date(2026, 1, 2, 3, 4, 6, 0, time.UTC), + Severity: "INFO", + Message: "server started", + } + + m := &DashboardModel{} + got := m.formatLogEntryForClipboard(entry) + + if strings.Contains(got, "Attributes:") { + t.Errorf("expected no Attributes section for entry with no attributes, got:\n%s", got) + } + if strings.Contains(got, "AI Analysis") { + t.Errorf("expected no AI Analysis section when aiAnalysisResult is empty, got:\n%s", got) + } + if strings.Contains(got, "Log Time") { + t.Errorf("expected no Log Time line when OrigTimestamp is zero, got:\n%s", got) + } +} + +func TestFormatLogEntryForClipboard_IncludesAIAnalysisWhenPresent(t *testing.T) { + entry := LogEntry{Timestamp: time.Now(), Severity: "WARN", Message: "retrying request"} + + m := &DashboardModel{aiAnalysisResult: "This looks like a transient network blip."} + got := m.formatLogEntryForClipboard(entry) + + if !strings.Contains(got, "AI Analysis:\nThis looks like a transient network blip.") { + t.Errorf("expected AI analysis to be included, got:\n%s", got) + } +} + +func TestFormatLogEntryForClipboard_SkipsPlaceholderAIAnalysis(t *testing.T) { + entry := LogEntry{Timestamp: time.Now(), Severity: "WARN", Message: "retrying request"} + + // "Analyzing..." is the transient placeholder set while the AI request is + // in flight (see navigation.go's "i" case) — it must never be copied. + m := &DashboardModel{aiAnalysisResult: "Analyzing..."} + got := m.formatLogEntryForClipboard(entry) + + if strings.Contains(got, "AI Analysis") { + t.Errorf("expected placeholder 'Analyzing...' result to be excluded, got:\n%s", got) + } +} + +func TestSetCopyFeedback(t *testing.T) { + m := &DashboardModel{} + + m.setCopyFeedback(nil, "message") + if m.copyFeedback != "Copied message to clipboard" { + t.Errorf("setCopyFeedback(nil, %q) = %q, want %q", "message", m.copyFeedback, "Copied message to clipboard") + } + if !m.copyFeedbackExpiry.After(time.Now()) { + t.Errorf("expected copyFeedbackExpiry to be set in the future") + } +} diff --git a/internal/tui/modal_base.go b/internal/tui/modal_base.go index 74eb39b..70f617f 100644 --- a/internal/tui/modal_base.go +++ b/internal/tui/modal_base.go @@ -105,6 +105,7 @@ func (m *DashboardModel) renderModalStatusBar() string { } else { statusItems = append(statusItems, "w: Enable wrapping") } + statusItems = append(statusItems, "y: Copy message", "Ctrl+y: Copy all") statusItems = append(statusItems, "↑↓/Wheel: Scroll", "PgUp/PgDn: Page") } } else { @@ -119,7 +120,16 @@ func (m *DashboardModel) renderModalStatusBar() string { statusStyle := lipgloss.NewStyle(). Foreground(ColorGray) - return statusStyle.Render(strings.Join(statusItems, " • ")) + rendered := statusStyle.Render(strings.Join(statusItems, " • ")) + + // Prepend transient copy feedback (e.g. "Copied message to clipboard"), + // cleared automatically on TickMsg once copyFeedbackExpiry passes. + if m.copyFeedback != "" { + feedbackStyle := lipgloss.NewStyle().Foreground(ColorGreen) + rendered = feedbackStyle.Render(m.copyFeedback) + statusStyle.Render(" • ") + rendered + } + + return rendered } // getSeverityColor returns the appropriate color for a severity level diff --git a/internal/tui/modal_help.go b/internal/tui/modal_help.go index f7c29ae..a42f4f7 100644 --- a/internal/tui/modal_help.go +++ b/internal/tui/modal_help.go @@ -84,6 +84,8 @@ ACTIONS: u/U - Cycle update intervals (forward/backward) i - Show comprehensive statistics modal i - AI analysis (when viewing log details) + y - Copy log message (when viewing log details) + Ctrl+y - Copy full log entry + attributes + AI analysis (when viewing log details) m - Switch AI model (shows available models) d - Open Dstl8 Lite web dashboard in browser w - Show what's new (release notes) diff --git a/internal/tui/model.go b/internal/tui/model.go index 6983247..f6c8278 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -173,6 +173,10 @@ type DashboardModel struct { // Modal section navigation modalActiveSection string // "info" or "chat" + // Clipboard feedback (transient status message shown after a copy action) + copyFeedback string + copyFeedbackExpiry time.Time + // Model selection modal showModelSelectionModal bool selectedModelIndex int diff --git a/internal/tui/navigation.go b/internal/tui/navigation.go index a95d7bc..e318a33 100644 --- a/internal/tui/navigation.go +++ b/internal/tui/navigation.go @@ -1184,6 +1184,22 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + case "ctrl+y": + // Copy full log entry (message, severity, timestamps, attributes, + // and AI analysis if present) to the clipboard - only when not + // actively typing in chat + if !m.chatActive && m.currentLogEntry != nil { + text := m.formatLogEntryForClipboard(*m.currentLogEntry) + m.setCopyFeedback(copyToClipboard(text), "full log entry") + return m, nil + } + case "y", "alt+y": + // Copy just the log message to the clipboard - only when not + // actively typing in chat + if !m.chatActive && m.currentLogEntry != nil { + m.setCopyFeedback(copyToClipboard(m.currentLogEntry.Message), "message") + return m, nil + } case "escape", "esc": // escape to close modal (only if not in chat mode) m.showModal = false m.modalContent = "" diff --git a/internal/tui/update.go b/internal/tui/update.go index c49c560..4bd2450 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -42,6 +42,11 @@ func (m *DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update processing rate statistics on every tick m.updateProcessingRateStats() + // Clear transient copy-to-clipboard feedback once it has expired + if m.copyFeedback != "" && time.Now().After(m.copyFeedbackExpiry) { + m.copyFeedback = "" + } + // Only refresh charts when not paused if !m.viewPaused { m.updateCharts()