Skip to content
Open
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
68 changes: 68 additions & 0 deletions internal/tui/clipboard.go
Original file line number Diff line number Diff line change
@@ -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()
}
101 changes: 101 additions & 0 deletions internal/tui/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
12 changes: 11 additions & 1 deletion internal/tui/modal_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/modal_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/tui/navigation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
5 changes: 5 additions & 0 deletions internal/tui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down