返回 DeepSeek-Reasonix
resume_picker.go
根目录 / internal / cli / resume_picker.go
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
223 lines GO