Skip to content
Merged
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
94 changes: 84 additions & 10 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 7 additions & 1 deletion internal/tui/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -2151,16 +2151,22 @@ 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
// no-op cache hit while the scroll position is left at block 0 from
// RefreshFoldPreview→ScrollToBlock.
if !a.liveTail {
a.updateConvPreview()
if a.conv.split.Folds != nil {
a.conv.split.Preview.YOffset = prevYOffset
}
}
return nil
}
Expand Down
75 changes: 75 additions & 0 deletions internal/tui/copymode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
}
2 changes: 1 addition & 1 deletion internal/tui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/interactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"),
}
}

Expand Down
Loading
Loading