| 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 |