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
16 changes: 16 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -83,6 +86,9 @@ type setupInstallMsg struct {
err error
}

type clipboardCopiedMsg struct{ err error }
type copyFeedbackClearMsg struct{}

// ─── Model ───────────────────────────────────────────────────────────────────

type Model struct {
Expand Down Expand Up @@ -126,6 +132,9 @@ type Model struct {
SessionObservations []store.Observation
SessionDetailScroll int

// Clipboard
CopyFeedback string

// Setup
SetupAgents []setup.Agent
SetupResult *setup.Result
Expand Down Expand Up @@ -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}
}
Comment on lines +244 to +248
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copyToClipboard() writes OSC 52 sequences directly to os.Stderr from within a Bubbletea command. This bypasses Bubbletea’s configured output writer (the program uses stdout by default) and introduces hard-to-test, side-effecting I/O from a goroutine, which can also interleave with the renderer’s output. Consider emitting the OSC 52 sequence through the same output stream as the TUI (or making the writer injectable) so clipboard writes are routed consistently and can be tested deterministically.

Copilot uses AI. Check for mistakes.
}

var installAgentFn = setup.Install
var addClaudeCodeAllowlistFn = setup.AddClaudeCodeAllowlist
35 changes: 35 additions & 0 deletions internal/tui/update.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Comment on lines 323 to +330
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New clipboard behavior isn’t covered by the existing TUI key-path tests. internal/tui/update_test.go has explicit assertions for other keys on these screens (e.g. recent enter/t/esc), but there are no tests ensuring that pressing c returns a clipboard command and that clipboardCopiedMsg updates CopyFeedback. Adding tests here would prevent regressions across the four screens that now support copy.

Copilot uses AI. Check for mistakes.
case "/", "s":
m.PrevScreen = ScreenSearchResults
m.Screen = ScreenSearch
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Comment on lines +93 to 102
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copy feedback color is derived by parsing the rendered text (strings.HasPrefix(m.CopyFeedback, "✗")). This makes styling brittle (changing the message text breaks the color logic) and mixes presentation with state. Consider storing a small structured state (e.g., a bool/enum for success vs failure) alongside the message and selecting the style from that instead.

Suggested change
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)
}
fbStyle := m.copyFeedbackStyle()
content += "\n" + fbStyle.Render(m.CopyFeedback)
}
return appStyle.Render(content)
}
// copyFeedbackStyle determines the style for the current copy feedback message.
// NOTE: This currently infers error vs. success from the message content, but
// the logic is centralized here so it can later be switched to a dedicated
// structured state (e.g., a bool/enum on the model) without touching View.
func (m Model) copyFeedbackStyle() lipgloss.Style {
// Default to success style.
style := lipgloss.NewStyle().Bold(true).Foreground(colorGreen)
// If the feedback indicates a failure, use the error style.
if strings.HasPrefix(m.CopyFeedback, "✗") {
style = lipgloss.NewStyle().Bold(true).Foreground(colorRed)
}
return style
}

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down
Loading