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()