返回 DeepSeek-Reasonix
resume.go
根目录 / internal / cli / resume.go
1 package cli
2
3 import (
4 "fmt"
5 "sort"
6 "strconv"
7 "strings"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/i18n"
11 )
12
13 const resumeListCap = 10
14
15 // recentSessions returns the newest saved sessions under dir. It keeps recovery
16 // groups intact at the display cap (a single group may make the result slightly
17 // larger) so the 1-based indices match /resume <n> and its completion without
18 // orphaning a conflict copy from its parent. A read error yields an empty list.
19 func recentSessions(dir string) []agent.SessionInfo {
20 if dir == "" {
21 return nil
22 }
23 sessions, err := agent.ListSessions(dir)
24 if err != nil {
25 return nil
26 }
27 sessions = orderResumeSessions(sessions)
28 return capResumeSessionGroups(sessions, resumeListCap)
29 }
30
31 // mostRecentSession returns the chronologically newest saved session for
32 // --continue. Interactive resume surfaces deliberately group recovery families
33 // and prefer visible leaves, but --continue promises the most recent session and
34 // must not let that presentation ordering select an older recovery copy.
35 func mostRecentSession(dir string) (agent.SessionInfo, bool) {
36 if dir == "" {
37 return agent.SessionInfo{}, false
38 }
39 sessions, err := agent.ListSessions(dir)
40 if err != nil || len(sessions) == 0 {
41 return agent.SessionInfo{}, false
42 }
43 return sessions[0], true
44 }
45
46 func capResumeSessionGroups(sessions []agent.SessionInfo, limit int) []agent.SessionInfo {
47 if limit <= 0 || len(sessions) <= limit {
48 return sessions
49 }
50 byID := make(map[string]agent.SessionInfo, len(sessions))
51 for _, session := range sessions {
52 byID[agent.BranchID(session.Path)] = session
53 }
54 out := make([]agent.SessionInfo, 0, limit)
55 for start := 0; start < len(sessions); {
56 key := recoveryResumeGroupKey(sessions[start], byID)
57 end := start + 1
58 for end < len(sessions) && recoveryResumeGroupKey(sessions[end], byID) == key {
59 end++
60 }
61 if len(out) > 0 && len(out)+(end-start) > limit {
62 break
63 }
64 out = append(out, sessions[start:end]...)
65 start = end
66 if len(out) >= limit {
67 break
68 }
69 }
70 return out
71 }
72
73 // orderResumeSessions keeps conflict-recovery copies next to the session they
74 // came from. Groups remain newest-first, while the newest visible leaf is first
75 // within each group so interactive picker and numbered resume surfaces present
76 // the most likely writable continuation before its ancestors.
77 func orderResumeSessions(sessions []agent.SessionInfo) []agent.SessionInfo {
78 if len(sessions) < 2 {
79 return sessions
80 }
81 byID := make(map[string]agent.SessionInfo, len(sessions))
82 for _, session := range sessions {
83 byID[agent.BranchID(session.Path)] = session
84 }
85 type resumeGroup struct {
86 items []agent.SessionInfo
87 newest int
88 activity int64
89 }
90 groups := make(map[string]*resumeGroup, len(sessions))
91 order := make([]*resumeGroup, 0, len(sessions))
92 for i, session := range sessions {
93 key := recoveryResumeGroupKey(session, byID)
94 group := groups[key]
95 if group == nil {
96 group = &resumeGroup{newest: i}
97 groups[key] = group
98 order = append(order, group)
99 }
100 group.items = append(group.items, session)
101 if stamp := session.ModTime.UnixNano(); stamp > group.activity {
102 group.activity = stamp
103 }
104 }
105 sort.SliceStable(order, func(i, j int) bool {
106 if order[i].activity == order[j].activity {
107 return order[i].newest < order[j].newest
108 }
109 return order[i].activity > order[j].activity
110 })
111
112 out := make([]agent.SessionInfo, 0, len(sessions))
113 for _, group := range order {
114 children := make(map[string]bool, len(group.items))
115 members := make(map[string]bool, len(group.items))
116 for _, session := range group.items {
117 members[agent.BranchID(session.Path)] = true
118 }
119 for _, session := range group.items {
120 parentID := strings.TrimSpace(session.ParentID)
121 if members[parentID] {
122 children[parentID] = true
123 }
124 }
125 sort.SliceStable(group.items, func(i, j int) bool {
126 iLeaf := !children[agent.BranchID(group.items[i].Path)]
127 jLeaf := !children[agent.BranchID(group.items[j].Path)]
128 if iLeaf != jLeaf {
129 return iLeaf
130 }
131 return group.items[i].ModTime.After(group.items[j].ModTime)
132 })
133 out = append(out, group.items...)
134 }
135 return out
136 }
137
138 func recoveryResumeGroupKey(session agent.SessionInfo, byID map[string]agent.SessionInfo) string {
139 id := agent.BranchID(session.Path)
140 if !session.Recovered {
141 return id
142 }
143 seen := map[string]bool{id: true}
144 current := session
145 for {
146 parentID := strings.TrimSpace(current.ParentID)
147 if parentID == "" {
148 return agent.BranchID(current.Path)
149 }
150 if seen[parentID] {
151 return "recovery-cycle:" + parentID
152 }
153 seen[parentID] = true
154 parent, ok := byID[parentID]
155 if !ok {
156 return "recovery-parent:" + parentID
157 }
158 if !parent.Recovered {
159 return parentID
160 }
161 current = parent
162 }
163 }
164
165 // runResumeCommand handles "/resume": with no argument it opens the recent
166 // session picker; "/resume <n>" loads that
167 // session into the running controller in place — keeping the current model and
168 // replaying the transcript into scrollback.
169 func (m *chatTUI) runResumeCommand(input string) {
170 args := tokenizeArgs(input) // args[0] == "/resume"
171 if len(args) < 2 {
172 m.openResumePicker()
173 return
174 }
175 // Do not run recovery GC between displaying/completing a numeric index and
176 // resolving it here. Removing an earlier row would silently retarget the
177 // user's already-selected number. Bare /resume performs cleanup before it
178 // builds the picker, and startup performs the ordinary background sweep.
179 sessions := recentSessions(m.ctrl.SessionDir())
180 if len(sessions) == 0 {
181 m.notice(i18n.M.NoSessionToResume)
182 return
183 }
184 if m.ctrl.Running() {
185 m.notice(i18n.M.ResumeBusy)
186 return
187 }
188 idx, err := strconv.Atoi(strings.TrimSpace(args[1]))
189 if err != nil || idx < 1 || idx > len(sessions) {
190 m.notice(fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(sessions)))
191 return
192 }
193 target := sessions[idx-1]
194 if target.Path == m.ctrl.SessionPath() {
195 m.notice(i18n.M.ResumeAlreadyActive)
196 return
197 }
198 loaded, err := agent.LoadSession(target.Path)
199 if err != nil {
200 m.notice("resume: " + err.Error())
201 return
202 }
203 // Persist the conversation we're leaving so switching back later restores it.
204 // Snapshot before moving the lease: the outgoing session must be written
205 // while this process still owns it.
206 _ = m.ctrl.Snapshot()
207 m.followSessionLease()
208 if err := m.rebindSessionLease(target.Path); err != nil {
209 m.notice("resume: " + sessionLeaseHeldNotice(err))
210 return
211 }
212 m.ctrl.Resume(loaded, target.Path)
213 m.replayActiveBranch(i18n.M.ResumedTitle)
214 }
215
216 // resumeArgItems completes the index argument of "/resume <n>": once past the
217 // command word it lists recent sessions, inserting the 1-based index and
218 // showing timestamp + turn count + preview as the hint. Indices match
219 // the picker because both window through recentSessions.
220 func (m *chatTUI) resumeArgItems(val string) ([]compItem, int, bool) {
221 cmdEnd := strings.IndexAny(val, " \t")
222 if cmdEnd < 0 || val[:cmdEnd] != "/resume" {
223 return nil, 0, false
224 }
225 from := strings.LastIndexAny(val, " \t") + 1
226 if len(strings.Fields(val[:from])) != 1 || m.ctrl == nil {
227 return nil, from, true
228 }
229 cur := val[from:]
230 var out []compItem
231 for i, s := range recentSessions(m.ctrl.SessionDir()) {
232 idx := strconv.Itoa(i + 1)
233 if cur != "" && !strings.HasPrefix(idx, cur) {
234 continue
235 }
236 hint := fmt.Sprintf("%s · %s", s.ModTime.Local().Format("01-02 15:04"), sessionSummary(s))
237 out = append(out, compItem{label: idx, insert: idx, hint: hint})
238 }
239 return out, from, true
240 }
241
242 // sessionSummary is the "N turns · display title" line shared by the /resume
243 // list and its argument completion. Explicit session renames win, then topic
244 // titles, then the raw preview so the user can identify sessions at a glance.
245 func sessionSummary(s agent.SessionInfo) string {
246 preview := s.CustomTitle
247 if preview == "" {
248 preview = s.TopicTitle
249 }
250 if preview == "" {
251 preview = s.Preview
252 }
253 if preview == "" {
254 preview = "(no user message yet)"
255 }
256 return recoverySessionBadge(s) + fmt.Sprintf("%d turns · %s", s.Turns, preview)
257 }
258
259 func recoverySessionBadge(s agent.SessionInfo) string {
260 if !s.Recovered {
261 return ""
262 }
263 parent := strings.TrimSpace(s.ParentID)
264 if len(parent) > 8 {
265 parent = parent[:8]
266 }
267 if parent == "" {
268 parent = "?"
269 }
270 return fmt.Sprintf(i18n.M.ResumeRecoveryBadgeFmt, parent) + " "
271 }
272
272 lines GO