| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | |
| 8 | tea "charm.land/bubbletea/v2" |
| 9 | "github.com/charmbracelet/x/ansi" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/i18n" |
| 13 | "reasonix/internal/session" |
| 14 | ) |
| 15 | |
| 16 | // resumePicker is an in-chat overlay for "/resume" that lets the user pick a |
| 17 | // saved session by navigating with ↑/↓ and confirming with Enter. It mirrors |
| 18 | // the rewindPicker pattern: keys route through handleResumePickerKey and it |
| 19 | // renders via renderResumePicker while m.resumePick is set. |
| 20 | type resumePicker struct { |
| 21 | entries []resumeEntry |
| 22 | sel int // selected index |
| 23 | active int // index of the currently-active session (-1 when none) |
| 24 | quick *quickPicker |
| 25 | } |
| 26 | |
| 27 | // openResumePicker populates the picker from the session directory and opens it. |
| 28 | // A no-op (with a notice) when there are no saved sessions. |
| 29 | func (m *chatTUI) openResumePicker() { |
| 30 | reclaimCLIRecoveryBranches(m.ctrl.SessionDir()) |
| 31 | entries := resumeEntries(m.ctrl.SessionDir()) |
| 32 | if len(entries) == 0 { |
| 33 | m.notice(i18n.M.NoSessionToResume) |
| 34 | return |
| 35 | } |
| 36 | active := m.ctrl.SessionPath() |
| 37 | activeIdx := -1 |
| 38 | reclaimedIdx := -1 |
| 39 | for i, entry := range entries { |
| 40 | if entry.session.Path == active || resumeEntryIsActive(m.ctrl, entry) { |
| 41 | activeIdx = i |
| 42 | } |
| 43 | if resumeTargetMatches(m.reclaimedTarget, entry) { |
| 44 | reclaimedIdx = i |
| 45 | } |
| 46 | } |
| 47 | // Default selection: the first session after the active one, else 0. |
| 48 | sel := 0 |
| 49 | if activeIdx >= 0 && activeIdx+1 < len(entries) { |
| 50 | sel = activeIdx + 1 |
| 51 | } |
| 52 | if m.sessionReclaimed && reclaimedIdx >= 0 && len(entries) > 1 { |
| 53 | sel = reclaimedIdx + 1 |
| 54 | if sel >= len(entries) { |
| 55 | sel = 0 |
| 56 | } |
| 57 | } |
| 58 | items := make([]quickPickerItem, 0, len(entries)) |
| 59 | for i, entry := range entries { |
| 60 | status := "" |
| 61 | switch i { |
| 62 | case activeIdx: |
| 63 | status = "active" |
| 64 | case reclaimedIdx: |
| 65 | status = "taken back" |
| 66 | } |
| 67 | label := sessionPickerLabel(entry.session) |
| 68 | description := entry.session.ModTime.Local().Format("2006-01-02 15:04") |
| 69 | if entry.project != "" { |
| 70 | label = fmt.Sprintf("[%s] %s", entry.project, label) |
| 71 | description = entry.project + " · " + description |
| 72 | } |
| 73 | items = append(items, quickPickerItem{ |
| 74 | ID: entry.session.Path, Label: label, |
| 75 | Description: description, Status: status, |
| 76 | }) |
| 77 | } |
| 78 | m.resumePick = &resumePicker{ |
| 79 | entries: entries, sel: sel, active: activeIdx, |
| 80 | quick: &quickPicker{kind: quickPickerResume, title: i18n.M.ResumePickTitle, items: items, selected: sel}, |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func resumeTargetMatches(target cliResumeTarget, entry resumeEntry) bool { |
| 85 | if target.canonical() || entry.target.canonical() { |
| 86 | return target.canonical() && entry.target.canonical() && target.ref == entry.target.ref |
| 87 | } |
| 88 | return target.path != "" && agent.CanonicalSessionPath(target.path) == agent.CanonicalSessionPath(entry.session.Path) |
| 89 | } |
| 90 | |
| 91 | func (m chatTUI) handleResumePickerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
| 92 | r := m.resumePick |
| 93 | if r == nil { |
| 94 | return m, nil |
| 95 | } |
| 96 | if r.quick != nil { |
| 97 | result := r.quick.handleKey(msg) |
| 98 | r.sel = r.quick.selected |
| 99 | if result.cancelled { |
| 100 | m.resumePick = nil |
| 101 | return m, nil |
| 102 | } |
| 103 | if result.choice != nil { |
| 104 | for i, entry := range r.entries { |
| 105 | if entry.session.Path == result.choice.ID { |
| 106 | r.sel = i |
| 107 | break |
| 108 | } |
| 109 | } |
| 110 | return m.applyResumePick() |
| 111 | } |
| 112 | return m, nil |
| 113 | } |
| 114 | switch msg.String() { |
| 115 | case "up", "k": |
| 116 | if r.sel > 0 { |
| 117 | r.sel-- |
| 118 | } |
| 119 | case "down", "j": |
| 120 | if r.sel < len(r.entries)-1 { |
| 121 | r.sel++ |
| 122 | } |
| 123 | case "enter": |
| 124 | return m.applyResumePick() |
| 125 | case "esc": |
| 126 | m.resumePick = nil |
| 127 | } |
| 128 | return m, nil |
| 129 | } |
| 130 | |
| 131 | func (m chatTUI) applyResumePick() (tea.Model, tea.Cmd) { |
| 132 | r := m.resumePick |
| 133 | if r == nil || r.sel < 0 || r.sel >= len(r.entries) { |
| 134 | return m, nil |
| 135 | } |
| 136 | target := r.entries[r.sel] |
| 137 | m.resumePick = nil |
| 138 | if resumeEntryIsActive(m.ctrl, target) { |
| 139 | m.notice(i18n.M.ResumeAlreadyActive) |
| 140 | return m, nil |
| 141 | } |
| 142 | if m.ctrl.Running() { |
| 143 | m.notice(i18n.M.ResumeBusy) |
| 144 | return m, nil |
| 145 | } |
| 146 | detached := m.sessionReclaimed || m.takeover != nil && m.takeover.Returned() |
| 147 | if !detached { |
| 148 | // Snapshot before moving the lease: the outgoing session must be written |
| 149 | // while this process still owns it. |
| 150 | if err := m.ctrl.Snapshot(); err != nil { |
| 151 | m.notice("resume: snapshot current session: " + err.Error()) |
| 152 | return m, nil |
| 153 | } |
| 154 | m.followSessionLease() |
| 155 | } |
| 156 | if target.target.canonical() { |
| 157 | if err := m.commitCanonicalSessionSwitch(target.target.ref); err != nil { |
| 158 | if !detached { |
| 159 | m.restoreSessionLease() |
| 160 | } |
| 161 | if errors.Is(err, session.ErrWriterOwned) { |
| 162 | m.pendingTakeoverPath = cliCanonicalRoute(target.target.ref.SessionID) |
| 163 | m.notice("resume: " + sessionWriterHeldNotice()) |
| 164 | m.notice("run /takeover to take this session over") |
| 165 | return m, nil |
| 166 | } |
| 167 | m.notice("resume: " + err.Error()) |
| 168 | return m, nil |
| 169 | } |
| 170 | } else if err := m.commitSessionSwitch(target.session.Path); err != nil { |
| 171 | m.notice("resume: " + sessionLeaseHeldNotice(err)) |
| 172 | if cliSessionTakeoverCandidate(err) { |
| 173 | m.pendingTakeoverPath = target.session.Path |
| 174 | m.notice("run /takeover to take this session over") |
| 175 | } |
| 176 | return m, nil |
| 177 | } |
| 178 | m.resumeAfterReclaim() |
| 179 | m.replayActiveBranch(i18n.M.ResumedTitle) |
| 180 | return m, nil |
| 181 | } |
| 182 | |
| 183 | func (m chatTUI) renderResumePicker() string { |
| 184 | r := m.resumePick |
| 185 | if r == nil { |
| 186 | return "" |
| 187 | } |
| 188 | if r.quick != nil { |
| 189 | return r.quick.render(m.width) |
| 190 | } |
| 191 | w := max(m.width, 10) |
| 192 | var b strings.Builder |
| 193 | b.WriteString(accent(i18n.M.ResumePickTitle) + "\n") |
| 194 | for i, entry := range r.entries { |
| 195 | label := sessionPickerLabel(entry.session) |
| 196 | if entry.project != "" { |
| 197 | label = fmt.Sprintf("[%s] %s", entry.project, label) |
| 198 | } |
| 199 | if i == r.active { |
| 200 | label = dim(label) + " " + dim("(active)") |
| 201 | } |
| 202 | b.WriteString(rowLine(i == r.sel, i+1, "", label, false) + "\n") |
| 203 | } |
| 204 | b.WriteString(dim(i18n.M.ResumePickHint)) |
| 205 | return choicePanelStyle.Width(w).Render(b.String()) |
| 206 | } |
| 207 | |
| 208 | // sessionPickerLabel is the "N turns · display title" line, truncated to fit. |
| 209 | // Explicit session renames win, then topic titles, then the raw preview. |
| 210 | func sessionPickerLabel(s agent.SessionInfo) string { |
| 211 | preview := s.CustomTitle |
| 212 | if preview == "" { |
| 213 | preview = s.TopicTitle |
| 214 | } |
| 215 | if preview == "" { |
| 216 | preview = s.Preview |
| 217 | } |
| 218 | if preview == "" { |
| 219 | preview = "(no user message yet)" |
| 220 | } |
| 221 | return recoverySessionBadge(s) + fmt.Sprintf("%d turns · %s", s.Turns, ansi.Truncate(preview, 60, "…")) |
| 222 | } |
| 223 |