diff --git a/internal/tui/app.go b/internal/tui/app.go index c9a0cc5..bc3cefc 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1524,6 +1524,9 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.currentSess = sess return a, a.openConversation(sess) case km.Session.Select: + if sp.Focus && sp.Show { + return a, nil + } sess, ok := a.selectedSession() if !ok { return a, nil @@ -1885,13 +1888,15 @@ func (a *App) handleConvPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.C a.sessPreviewPinned = a.sessConvCursor < len(visible)-1 return a, nil, true case "c": - if a.sessConvCursor < len(visible) { - text := entryFullText(visible[a.sessConvCursor].entry) - if text != "" { - a.sessConvFullText = text - a.sessConvFullScroll = 0 - } + return a.openSessionPreviewFullText(visible), nil, true + case a.keymap.Session.Actions: + if sess, ok := a.selectedSession(); ok { + a.actionsSess = sess } + a.actionsMenu = true + return a, nil, true + case a.keymap.Actions.Copy: + a.copySessionPreviewSelection() return a, nil, true case "enter": m, cmd := a.jumpToConvMessage() @@ -2278,6 +2283,67 @@ func (a *App) copySelectedSessionPath() (tea.Model, tea.Cmd) { return a, nil } +func (a *App) openSessionPreviewFullText(visible []mergedMsg) *App { + if a.sessConvCursor < 0 || a.sessConvCursor >= len(visible) { + return a + } + text := entryFullText(visible[a.sessConvCursor].entry) + if text == "" { + return a + } + a.sessConvFullText = text + a.sessConvFullScroll = 0 + return a +} + +func (a *App) copySessionPreviewSelection() { + visible := a.convVisibleEntries() + if a.sessPreviewMode != sessPreviewConversation || a.sessConvCursor < 0 || a.sessConvCursor >= len(visible) { + a.copiedMsg = "Nothing to copy" + return + } + text := entryFullText(visible[a.sessConvCursor].entry) + if text == "" { + a.copiedMsg = "Nothing to copy" + return + } + if err := copyToClipboard(text); err != nil { + a.copiedMsg = "Copy failed" + return + } + a.copiedMsg = "Copied message!" +} + +func (a *App) copySessionAction() (tea.Model, tea.Cmd) { + if a.sessSplit.Focus && a.sessSplit.Show && a.sessPreviewMode == sessPreviewConversation { + a.copySessionPreviewSelection() + return a, nil + } + return a.copySelectedSessionPath() +} + +func (a *App) copySelectedSessionPaths(selected []session.Session) (tea.Model, tea.Cmd) { + var paths []string + for _, sess := range selected { + if sess.FilePath != "" { + paths = append(paths, sess.FilePath) + } + } + if len(paths) == 0 { + a.copiedMsg = "No session files" + return a, nil + } + if err := copyToClipboard(strings.Join(paths, "\n")); err != nil { + a.copiedMsg = "Copy failed" + return a, nil + } + a.copiedMsg = fmt.Sprintf("Copied %d session path", len(paths)) + if len(paths) != 1 { + a.copiedMsg += "s" + } + return a, nil +} + // --- Edit file with $EDITOR --- type editChoice struct { @@ -2430,10 +2496,14 @@ func (a *App) renderEditHintBox() string { return boxStyle.Render(body) } +func (a *App) sessionPreviewActionsActive() bool { + return a.sessSplit.Focus && a.sessSplit.Show && a.sessPreviewMode == sessPreviewConversation +} + func (a *App) handleActionsMenu(key string) (tea.Model, tea.Cmd) { a.actionsMenu = false a.copiedMsg = "" - if a.hasMultiSelection() { + if !a.sessionPreviewActionsActive() && a.hasMultiSelection() { return a.handleBulkActionsMenu(key) } akm := a.keymap.Actions @@ -2452,6 +2522,8 @@ func (a *App) handleActionsMenu(key string) (tea.Model, tea.Cmd) { return a.resumeSession(sess) case akm.CopyPath: return a.copySelectedSessionPath() + case akm.Copy: + return a.copySessionAction() case akm.Move: if sess.ProjectPath == "" { a.copiedMsg = "No project path" @@ -2597,6 +2669,8 @@ func (a *App) handleBulkActionsMenu(key string) (tea.Model, tea.Cmd) { return a.bulkKill(selected) case akm.Input: return a.bulkInput(selected) + case akm.Copy, akm.CopyPath: + return a.copySelectedSessionPaths(selected) case akm.Tags: // Collect all selected session IDs var sessIDs []string @@ -4764,14 +4838,14 @@ func (a *App) renderActionsHintBox() string { akm := a.keymap.Actions var lines []string - if a.hasMultiSelection() { + if a.hasMultiSelection() && !a.sessionPreviewActionsActive() { header := fmt.Sprintf("%d selected", len(a.selectedSet)) lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(header)) - lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.Kill))+d.Render(":kill")+sp+hl.Render(displayKey(akm.Input))+d.Render(":input")) + lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.Copy))+d.Render(":copy")+sp+hl.Render(displayKey(akm.Kill))+d.Render(":kill")+sp+hl.Render(displayKey(akm.Input))+d.Render(":input")) lines = append(lines, hl.Render(displayKey(akm.URLs))+d.Render(":urls")+sp+hl.Render(displayKey(akm.Files))+d.Render(":files")+sp+hl.Render(displayKey(akm.Changes))+d.Render(":changes")+sp+hl.Render(displayKey(akm.Tags))+d.Render(":tags")) } else { sess := a.actionsSess - lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Move))+d.Render(":move")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.CopyPath))+d.Render(":copy-path")) + lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Move))+d.Render(":move")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.Copy))+d.Render(":copy")+sp+hl.Render(displayKey(akm.CopyPath))+d.Render(":copy-path")) line2 := hl.Render(displayKey(akm.Worktree)) + d.Render(":worktree") + sp + hl.Render(displayKey(akm.URLs)) + d.Render(":urls") + sp + hl.Render(displayKey(akm.Files)) + d.Render(":files") + sp + hl.Render(displayKey(akm.Changes)) + d.Render(":changes") + sp + hl.Render(displayKey(akm.Tags)) + d.Render(":tags") if sess.HasMemory { line2 += sp + hl.Render(displayKey(akm.RemoveMem)) + d.Render(":rm-mem") diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index 002d0f4..5a6f1d3 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -2151,9 +2151,12 @@ func (a *App) refreshConversation() tea.Cmd { a.conv.items = buildConvItems(a.conv.sess, a.conv.merged, agents, tasks, crons) a.conv.sess.Tasks = tasks - // Preserve cursor position + // Preserve list cursor and preview selection across the rebuild oldIdx := a.convList.Index() + prevCacheKey := a.conv.split.CacheKey + prevYOffset := a.conv.split.Preview.YOffset a.rebuildConversationList(oldIdx) + a.conv.split.CacheKey = prevCacheKey // During live tail, skip preview update here — handleLiveTail owns the // preview lifecycle (select last → update → scroll-to-tail). Updating here // would "consume" the CacheKey change, making handleLiveTail's update a @@ -2161,6 +2164,9 @@ func (a *App) refreshConversation() tea.Cmd { // RefreshFoldPreview→ScrollToBlock. if !a.liveTail { a.updateConvPreview() + if a.conv.split.Folds != nil { + a.conv.split.Preview.YOffset = prevYOffset + } } return nil } diff --git a/internal/tui/copymode.go b/internal/tui/copymode.go index fe77eb1..1514631 100644 --- a/internal/tui/copymode.go +++ b/internal/tui/copymode.go @@ -216,3 +216,78 @@ func (a *App) renderCopyMode() { vp.SetContent(sb.String()) vp.YOffset = offset } + +// copyConvSelection copies the currently selected conversation preview content +// to the clipboard. If blocks are explicitly selected (via space toggling), only +// those blocks are copied; otherwise the block under the cursor is copied. When +// no fold state exists yet, falls back to the message-level text. +func (a *App) copyConvSelection() { + sp := &a.conv.split + if sp.Folds == nil || len(sp.Folds.Entry.Content) == 0 { + a.copyConvSelectedMessage() + return + } + fs := sp.Folds + if len(fs.Selected) > 0 { + var parts []string + count := 0 + for i, block := range fs.Entry.Content { + if !fs.Selected[i] { + continue + } + if text := blockPlainText(block); text != "" { + parts = append(parts, text) + count++ + } + } + if count == 0 { + a.copiedMsg = "Nothing to copy" + return + } + copyToClipboard(strings.Join(parts, "\n\n")) + a.copiedMsg = fmt.Sprintf("Copied %d block", count) + if count != 1 { + a.copiedMsg += "s" + } + a.copiedMsg += "!" + fs.Selected = nil + sp.RefreshFoldPreview(a.width, a.splitRatio) + return + } + if fs.BlockCursor >= 0 && fs.BlockCursor < len(fs.Entry.Content) { + text := blockPlainText(fs.Entry.Content[fs.BlockCursor]) + if text != "" { + copyToClipboard(text) + a.copiedMsg = "Copied block!" + return + } + } + a.copyConvSelectedMessage() +} + +// copyConvSelectedMessage copies the full text of the currently selected +// conversation list item, used when there is no block-level selection. +func (a *App) copyConvSelectedMessage() { + item, ok := a.convList.SelectedItem().(convItem) + if !ok { + a.copiedMsg = "Nothing to copy" + return + } + var entry session.Entry + switch item.kind { + case convMsg: + entry = item.merged.entry + case convAgent: + entry = buildAgentPreviewEntry(item.agent) + default: + a.copiedMsg = "Nothing to copy" + return + } + text := entryFullText(entry) + if text == "" { + a.copiedMsg = "Nothing to copy" + return + } + copyToClipboard(text) + a.copiedMsg = "Copied message!" +} diff --git a/internal/tui/help.go b/internal/tui/help.go index 0be60e8..6ea6bc1 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -74,7 +74,7 @@ func (a *App) sessHelpLine() string { } else if a.sessSplit.Focus { switch a.sessPreviewMode { case sessPreviewConversation: - h += " ↑↓:nav c:full " + fmtKey(sk.Open, "jump") + " ←:unfocus /:search tab:mode" + h += " ↑↓:nav c:full " + fmtKey(sk.Actions, "actions") + " " + fmtKey(sk.Open, "jump") + " ←:unfocus /:search tab:mode" case sessPreviewAgents: h += " ↑↓:nav " + fmtKey(sk.Open, "jump") + " ←:unfocus tab:mode" default: diff --git a/internal/tui/interactions.go b/internal/tui/interactions.go index 3e4759e..a929b18 100644 --- a/internal/tui/interactions.go +++ b/internal/tui/interactions.go @@ -10,9 +10,10 @@ import ( type interactionActionID string const ( - interactionActionURLs interactionActionID = "urls" - interactionActionFiles interactionActionID = "files" + interactionActionURLs interactionActionID = "urls" + interactionActionFiles interactionActionID = "files" interactionActionChanges interactionActionID = "changes" + interactionActionCopy interactionActionID = "copy" ) type interactionAction struct { @@ -184,6 +185,7 @@ func (a *App) conversationActionMenuActions() []interactionAction { bindAction(interactionActionURLs, a.keymap.Actions.URLs, "urls"), bindAction(interactionActionFiles, a.keymap.Actions.Files, "files"), bindAction(interactionActionChanges, a.keymap.Actions.Changes, "changes"), + bindAction(interactionActionCopy, a.keymap.Actions.Copy, "copy"), } } diff --git a/internal/tui/interactions_test.go b/internal/tui/interactions_test.go index d6b1c2b..ade0709 100644 --- a/internal/tui/interactions_test.go +++ b/internal/tui/interactions_test.go @@ -158,3 +158,147 @@ func TestHandleBulkActionsMenuOpensBulkChanges(t *testing.T) { t.Fatalf("expected change map populated for both sessions, got %d", len(app.urlChangeMap)) } } + +func TestHandleConvActionsMenuCopyCopiesSelectedBlock(t *testing.T) { + app := setupConvApp(t, testEntries(), 120, 30) + app.conv.split.Show = true + app.conv.split.Focus = true + app.keymap.Actions.Copy = "c" + + selectConvItemBy(t, app, func(ci convItem) bool { + return ci.kind == convMsg && ci.merged.entry.Role == "assistant" + }) + app.updateConvPreview() + if app.conv.split.Folds == nil || len(app.conv.split.Folds.Entry.Content) == 0 { + t.Fatal("expected fold state after preview update") + } + app.conv.split.Folds.BlockCursor = 0 + app.copiedMsg = "" + + m, _ := app.handleConvActionsMenu("c") + app = m.(*App) + if app.convActionsMenu { + t.Fatal("expected actions menu to close after handling copy") + } + if !strings.Contains(app.copiedMsg, "Copied") { + t.Fatalf("expected copy confirmation, got %q", app.copiedMsg) + } +} + +func TestHandleSessionPreviewActionsMenuCopyCopiesPreviewMessage(t *testing.T) { + entries := testEntries() + app := newTestApp(fakeSessions()) + app.sessSplit.Show = true + app.sessSplit.Focus = true + app.sessPreviewMode = sessPreviewConversation + app.sessConvEntries = filterConversation(mergeConversationTurns(entries)) + app.sessConvCursor = 0 + app.keymap.Session.Actions = "x" + app.keymap.Actions.Copy = "c" + + m, _, _ := app.handleConvPreviewKeys(&app.sessSplit, "x") + app = m.(*App) + if !app.actionsMenu { + t.Fatal("expected session preview actions menu to open") + } + + m, _ = app.handleActionsMenu("c") + app = m.(*App) + if app.actionsMenu { + t.Fatal("expected actions menu to close after copy") + } + if !strings.Contains(app.copiedMsg, "Copied message") { + t.Fatalf("expected preview copy confirmation, got %q", app.copiedMsg) + } +} + +func TestHandleSessionPreviewActionsMenuIgnoresExistingMultiSelection(t *testing.T) { + entries := testEntries() + app := newTestApp(fakeSessions()) + app.sessSplit.Show = true + app.sessSplit.Focus = true + app.sessPreviewMode = sessPreviewConversation + app.sessConvEntries = filterConversation(mergeConversationTurns(entries)) + app.sessConvCursor = 0 + app.selectedSet = map[string]bool{"bbb": true} + app.keymap.Session.Actions = "x" + + m, _, _ := app.handleConvPreviewKeys(&app.sessSplit, "x") + app = m.(*App) + if !app.actionsMenu { + t.Fatal("expected session preview actions menu to open") + } + + hint := stripANSI(app.renderActionsHintBox()) + if strings.Contains(hint, "selected") { + t.Fatalf("expected preview actions menu, got bulk hint %q", hint) + } + if !strings.Contains(hint, "copy-path") { + t.Fatalf("expected single-session action hint, got %q", hint) + } +} + +func TestHandleBulkActionsMenuCopyCopiesSelectedSessionPaths(t *testing.T) { + sessions := fakeSessions() + sessions[0].FilePath = "/tmp/a.jsonl" + sessions[1].FilePath = "/tmp/b.jsonl" + app := newTestApp(sessions) + app.selectedSet = map[string]bool{"aaa": true, "bbb": true} + app.actionsMenu = true + app.keymap.Actions.Copy = "c" + + m, _ := app.handleActionsMenu("c") + app = m.(*App) + if app.actionsMenu { + t.Fatal("expected bulk actions menu to close after copy") + } + if !strings.Contains(app.copiedMsg, "Copied 2 session paths") { + t.Fatalf("expected bulk copy confirmation, got %q", app.copiedMsg) + } +} + +func TestRefreshConversationPreservesFoldSelection(t *testing.T) { + entries := testEntries() + app := setupConvApp(t, entries, 120, 30) + app.conv.split.Show = true + + selectConvItemBy(t, app, func(ci convItem) bool { + return ci.kind == convMsg && ci.merged.entry.Role == "assistant" + }) + app.updateConvPreview() + + if app.conv.split.Folds == nil || len(app.conv.split.Folds.Entry.Content) == 0 { + t.Fatal("expected fold state populated before refresh") + } + prevCursor := 1 + if prevCursor >= len(app.conv.split.Folds.Entry.Content) { + prevCursor = len(app.conv.split.Folds.Entry.Content) - 1 + } + app.conv.split.Folds.BlockCursor = prevCursor + app.conv.split.Folds.Selected = foldSet{prevCursor: true} + prevListIdx := app.convList.Index() + prevCacheKey := app.conv.split.CacheKey + + // Simulate refreshConversation's rebuild step (no file I/O) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) + prevYOffset := app.conv.split.Preview.YOffset + app.rebuildConversationList(prevListIdx) + app.conv.split.CacheKey = prevCacheKey + app.updateConvPreview() + if app.conv.split.Folds != nil { + app.conv.split.Preview.YOffset = prevYOffset + } + + if app.convList.Index() != prevListIdx { + t.Fatalf("list cursor should be preserved across refresh: got %d want %d", app.convList.Index(), prevListIdx) + } + if app.conv.split.Folds == nil { + t.Fatal("fold state should remain after refresh") + } + if app.conv.split.Folds.BlockCursor != prevCursor { + t.Fatalf("block cursor should be preserved: got %d want %d", app.conv.split.Folds.BlockCursor, prevCursor) + } + if !app.conv.split.Folds.Selected[prevCursor] { + t.Fatal("block selection should be preserved across refresh") + } +} diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 372a5da..9b14d78 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -45,6 +45,7 @@ type ActionsKeymap struct { URLs string `yaml:"urls"` Files string `yaml:"files"` Changes string `yaml:"changes"` + Copy string `yaml:"copy"` Tags string `yaml:"tags"` ImportMem string `yaml:"import_mem"` RemoveMem string `yaml:"remove_mem"` @@ -138,7 +139,8 @@ func DefaultKeymap() Keymap { Jump: "j", URLs: "u", Files: "f", - Changes: "g", + Changes: "g", + Copy: "c", Tags: "t", ImportMem: "M", RemoveMem: "X", @@ -300,6 +302,9 @@ func mergeKeymap(dst *Keymap, src Keymap) { if src.Actions.Changes != "" { dst.Actions.Changes = src.Actions.Changes } + if src.Actions.Copy != "" { + dst.Actions.Copy = src.Actions.Copy + } if src.Actions.ImportMem != "" { dst.Actions.ImportMem = src.Actions.ImportMem } diff --git a/internal/tui/live_preview_test.go b/internal/tui/live_preview_test.go index 1035ea8..f86e6f2 100644 --- a/internal/tui/live_preview_test.go +++ b/internal/tui/live_preview_test.go @@ -9,6 +9,10 @@ import ( "github.com/sendbird/ccx/internal/tmux" ) +func init() { + clipboardWrite = func(_ string) error { return nil } +} + func newTestApp(sessions []session.Session) *App { app := NewApp(sessions, Config{TmuxEnabled: true}) m, _ := app.Update(tea.WindowSizeMsg{Width: 160, Height: 50}) diff --git a/internal/tui/msgfull.go b/internal/tui/msgfull.go index 8e1cc97..84a9f6d 100644 --- a/internal/tui/msgfull.go +++ b/internal/tui/msgfull.go @@ -162,6 +162,10 @@ func (a *App) handleMessageFullKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "x": a.convActionsMenu = true return a, nil + case a.keymap.Session.Refresh: + a.refreshMsgFull() + a.copiedMsg = "Refreshed" + return a, nil } // Search navigation (when search term is active) @@ -321,7 +325,55 @@ func (a *App) handleLiveTailMsgFull() { a.msgFull.vp.YOffset = maxOffset } -// refreshMsgFullPreview re-renders the message full viewport. +// refreshMsgFull reloads messages for the current message-full session, +// preserving the existing fold/cursor/selection state when possible. +func (a *App) refreshMsgFull() { + entries, err := session.LoadMessages(a.msgFull.sess.FilePath) + if err != nil { + return + } + a.msgFull.messages = entries + a.msgFull.merged = filterConversation(mergeConversationTurns(entries)) + + if len(a.msgFull.merged) == 0 { + return + } + + idx := a.msgFull.idx + if idx < 0 { + idx = 0 + } + if idx >= len(a.msgFull.merged) { + idx = len(a.msgFull.merged) - 1 + } + a.msgFull.idx = idx + + newEntry := a.msgFull.merged[idx].entry + fs := &a.msgFull.folds + oldEntry := fs.Entry + oldBlockCount := len(oldEntry.Content) + newBlockCount := len(newEntry.Content) + + if oldBlockCount == 0 || newBlockCount < oldBlockCount { + fs.Reset(newEntry) + } else { + fs.GrowBlocks(newEntry, oldBlockCount, nil, nil) + } + if fs.BlockCursor < 0 || fs.BlockCursor >= len(fs.Entry.Content) { + if last := fs.lastVisibleBlock(); last >= 0 { + fs.BlockCursor = last + } + } + + contentH := ContentHeight(a.height) + a.msgFull.content = renderFullMessage(newEntry, a.width) + if a.msgFull.vp.Width == 0 || a.msgFull.vp.Height == 0 { + a.msgFull.vp = viewport.New(a.width, contentH) + } + a.refreshMsgFullPreview() +} + + func (a *App) refreshMsgFullPreview() { fs := &a.msgFull.folds ro := renderOpts{visible: fs.BlockVisible, hideHooks: fs.HideHooks, selected: fs.Selected} diff --git a/internal/tui/selection.go b/internal/tui/selection.go index 2fd0f7b..e7132bd 100644 --- a/internal/tui/selection.go +++ b/internal/tui/selection.go @@ -229,12 +229,16 @@ func stripANSI(s string) string { return ansiRegex.ReplaceAllString(s, "") } -func copyToClipboard(text string) error { +var clipboardWrite = func(text string) error { cmd := exec.Command("pbcopy") cmd.Stdin = strings.NewReader(text) return cmd.Run() } +func copyToClipboard(text string) error { + return clipboardWrite(text) +} + func openInPager(styledContent string) tea.Cmd { plain := stripANSI(styledContent) tmpFile, err := os.CreateTemp("", "ccx-*.txt") diff --git a/internal/tui/session_keybindings_test.go b/internal/tui/session_keybindings_test.go index a2da5a6..31f0d43 100644 --- a/internal/tui/session_keybindings_test.go +++ b/internal/tui/session_keybindings_test.go @@ -71,6 +71,19 @@ func TestSessionsTabStillCyclesGroupMode(t *testing.T) { } } +func TestSessionsSpaceDoesNotSelectWhenPreviewFocused(t *testing.T) { + app := newSessionKeybindingApp() + app.sessSplit.Show = true + app.sessSplit.Focus = true + app.sessPreviewMode = sessPreviewConversation + + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeySpace}) + app = m.(*App) + if app.hasMultiSelection() { + t.Fatalf("space in focused preview should not multi-select session, got %v", app.selectedSet) + } +} + func TestSessionsHelpShowsNavigationAndTabGrouping(t *testing.T) { app := newSessionKeybindingApp() app.sessSplit.Show = false diff --git a/internal/tui/urls.go b/internal/tui/urls.go index 49d8a30..9928b86 100644 --- a/internal/tui/urls.go +++ b/internal/tui/urls.go @@ -34,6 +34,13 @@ func (a *App) handleConvActionsMenu(key string) (tea.Model, tea.Cmd) { return a.openMsgFullChangesMenu() } return a.openConvChangesMenu() + case interactionKeyMatches(actions, key, interactionActionCopy): + if a.state == viewMessageFull { + a.copyMsgFullBlocks() + return a, nil + } + a.copyConvSelection() + return a, nil } return a, nil }