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