From f7746fd940be9605df7b62dc61b54ce320d141f1 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Wed, 1 Apr 2026 16:16:19 +0200 Subject: [PATCH] feat(tui): add clipboard copy via OSC 52 on observation screens --- internal/tui/model.go | 16 ++++++++++++++++ internal/tui/update.go | 35 +++++++++++++++++++++++++++++++++++ internal/tui/view.go | 17 +++++++++++++---- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index b9a9e31..f37cffe 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,10 +10,13 @@ package tui import ( + "os" + "github.com/Gentleman-Programming/engram/internal/setup" "github.com/Gentleman-Programming/engram/internal/store" "github.com/Gentleman-Programming/engram/internal/version" + osc52 "github.com/aymanbagabas/go-osc52/v2" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -83,6 +86,9 @@ type setupInstallMsg struct { err error } +type clipboardCopiedMsg struct{ err error } +type copyFeedbackClearMsg struct{} + // ─── Model ─────────────────────────────────────────────────────────────────── type Model struct { @@ -126,6 +132,9 @@ type Model struct { SessionObservations []store.Observation SessionDetailScroll int + // Clipboard + CopyFeedback string + // Setup SetupAgents []setup.Agent SetupResult *setup.Result @@ -232,5 +241,12 @@ func installAgent(agentName string) tea.Cmd { } } +func copyToClipboard(text string) tea.Cmd { + return func() tea.Msg { + _, err := osc52.New(text).WriteTo(os.Stderr) + return clipboardCopiedMsg{err: err} + } +} + var installAgentFn = setup.Install var addClaudeCodeAllowlistFn = setup.AddClaudeCodeAllowlist diff --git a/internal/tui/update.go b/internal/tui/update.go index 44b54d7..a262600 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,6 +1,8 @@ package tui import ( + "time" + "github.com/Gentleman-Programming/engram/internal/setup" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" @@ -125,6 +127,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } return m, nil + + case clipboardCopiedMsg: + if msg.err != nil { + m.CopyFeedback = "✗ Copy failed" + } else { + m.CopyFeedback = "✓ Copied!" + } + return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return copyFeedbackClearMsg{} + }) + + case copyFeedbackClearMsg: + m.CopyFeedback = "" + return m, nil } return m, nil @@ -307,6 +323,11 @@ func (m Model) handleSearchResultsKeys(key string) (tea.Model, tea.Cmd) { m.PrevScreen = ScreenSearchResults return m, loadTimeline(m.store, obsID) } + case "c": + if len(m.SearchResults) > 0 && m.Cursor < len(m.SearchResults) { + r := m.SearchResults[m.Cursor] + return m, copyToClipboard(r.Content) + } case "/", "s": m.PrevScreen = ScreenSearchResults m.Screen = ScreenSearch @@ -358,6 +379,11 @@ func (m Model) handleRecentKeys(key string) (tea.Model, tea.Cmd) { m.PrevScreen = ScreenRecent return m, loadTimeline(m.store, obsID) } + case "c": + if len(m.RecentObservations) > 0 && m.Cursor < len(m.RecentObservations) { + obs := m.RecentObservations[m.Cursor] + return m, copyToClipboard(obs.Content) + } case "esc", "q": m.Screen = ScreenDashboard m.Cursor = 0 @@ -382,6 +408,10 @@ func (m Model) handleObservationDetailKeys(key string) (tea.Model, tea.Cmd) { if m.SelectedObservation != nil { return m, loadTimeline(m.store, m.SelectedObservation.ID) } + case "c": + if m.SelectedObservation != nil { + return m, copyToClipboard(m.SelectedObservation.Content) + } case "esc", "q": m.Screen = m.PrevScreen m.Cursor = 0 @@ -484,6 +514,11 @@ func (m Model) handleSessionDetailKeys(key string) (tea.Model, tea.Cmd) { m.PrevScreen = ScreenSessionDetail return m, loadTimeline(m.store, obsID) } + case "c": + if len(m.SessionObservations) > 0 && m.Cursor < len(m.SessionObservations) { + obs := m.SessionObservations[m.Cursor] + return m, copyToClipboard(obs.Content) + } case "esc", "q": m.Screen = ScreenSessions m.Cursor = m.SelectedSessionIdx diff --git a/internal/tui/view.go b/internal/tui/view.go index c54d3ae..fd8ef1d 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -88,6 +88,15 @@ func (m Model) View() string { content += "\n" + errorStyle.Render("Error: "+m.ErrorMsg) } + // Show copy feedback + if m.CopyFeedback != "" { + fbStyle := lipgloss.NewStyle().Bold(true).Foreground(colorGreen) + if strings.HasPrefix(m.CopyFeedback, "✗") { + fbStyle = lipgloss.NewStyle().Bold(true).Foreground(colorRed) + } + content += "\n" + fbStyle.Render(m.CopyFeedback) + } + return appStyle.Render(content) } @@ -226,7 +235,7 @@ func (m Model) viewSearchResults() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.Scroll+1, end, resultCount)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • t timeline • / search • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • / search • esc back")) return b.String() } @@ -268,7 +277,7 @@ func (m Model) viewRecent() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.Scroll+1, end, count)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • t timeline • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • esc back")) return b.String() } @@ -363,7 +372,7 @@ func (m Model) viewObservationDetail() string { timestampStyle.Render(fmt.Sprintf("line %d-%d of %d", m.DetailScroll+1, end, len(contentLines))))) } - b.WriteString(helpStyle.Render("\n j/k scroll • t timeline • esc back")) + b.WriteString(helpStyle.Render("\n j/k scroll • c copy • t timeline • esc back")) return b.String() } @@ -553,7 +562,7 @@ func (m Model) viewSessionDetail() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.SessionDetailScroll+1, end, count)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • t timeline • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter detail • c copy • t timeline • esc back")) return b.String() }