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