Files
2026-07-13 13:00:08 +08:00

139 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"fmt"
"strconv"
"strings"
"reasonix/internal/agent"
"reasonix/internal/i18n"
)
const resumeListCap = 10
// recentSessions returns the newest saved sessions under dir, capped so the
// 1-based indices the list shows match what /resume <n> and its completion
// resolve. A missing dir or read error yields an empty list.
func recentSessions(dir string) []agent.SessionInfo {
if dir == "" {
return nil
}
sessions, err := agent.ListSessions(dir)
if err != nil {
return nil
}
if len(sessions) > resumeListCap {
sessions = sessions[:resumeListCap]
}
return sessions
}
// runResumeCommand handles "/resume": with no argument it lists the most recent
// saved sessions (newest first, active one marked); "/resume <n>" loads that
// session into the running controller in place — keeping the current model and
// replaying the transcript into scrollback.
func (m *chatTUI) runResumeCommand(input string) {
sessions := recentSessions(m.ctrl.SessionDir())
if len(sessions) == 0 {
m.notice(i18n.M.NoSessionToResume)
return
}
args := tokenizeArgs(input) // args[0] == "/resume"
if len(args) < 2 {
m.showSessions(sessions) // write list to scrollback (above input)
m.openResumePicker() // open interactive picker below
return
}
if m.ctrl.Running() {
m.notice(i18n.M.ResumeBusy)
return
}
idx, err := strconv.Atoi(strings.TrimSpace(args[1]))
if err != nil || idx < 1 || idx > len(sessions) {
m.notice(fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(sessions)))
return
}
target := sessions[idx-1]
if target.Path == m.ctrl.SessionPath() {
m.notice(i18n.M.ResumeAlreadyActive)
return
}
loaded, err := agent.LoadSession(target.Path)
if err != nil {
m.notice("resume: " + err.Error())
return
}
// Persist the conversation we're leaving so switching back later restores it.
// Snapshot before moving the lease: the outgoing session must be written
// while this process still owns it.
_ = m.ctrl.Snapshot()
m.followSessionLease()
if err := m.rebindSessionLease(target.Path); err != nil {
m.notice("resume: " + sessionLeaseHeldNotice(err))
return
}
m.ctrl.Resume(loaded, target.Path)
m.replayActiveBranch(i18n.M.ResumedTitle)
}
// showSessions renders the recent-session list with 1-based indices, timestamp,
// turn count and preview, marking the one currently active.
func (m *chatTUI) showSessions(sessions []agent.SessionInfo) {
active := m.ctrl.SessionPath()
var b strings.Builder
b.WriteString(dim(" · " + i18n.M.ResumeListHeader + "\n"))
for i, s := range sessions {
marker := " "
if s.Path == active {
marker = accent(" ")
}
fmt.Fprintf(&b, "%s%d %s %s\n", marker, i+1,
s.ModTime.Local().Format("01-02 15:04"), dim(sessionSummary(s)))
}
m.notice(strings.TrimRight(b.String(), "\n"))
}
// resumeArgItems completes the index argument of "/resume <n>": once past the
// command word it lists recent sessions, inserting the 1-based index and
// showing timestamp + turn count + preview as the hint. Indices match
// showSessions because both window through recentSessions.
func (m *chatTUI) resumeArgItems(val string) ([]compItem, int, bool) {
cmdEnd := strings.IndexAny(val, " \t")
if cmdEnd < 0 || val[:cmdEnd] != "/resume" {
return nil, 0, false
}
from := strings.LastIndexAny(val, " \t") + 1
if len(strings.Fields(val[:from])) != 1 || m.ctrl == nil {
return nil, from, true
}
cur := val[from:]
var out []compItem
for i, s := range recentSessions(m.ctrl.SessionDir()) {
idx := strconv.Itoa(i + 1)
if cur != "" && !strings.HasPrefix(idx, cur) {
continue
}
hint := fmt.Sprintf("%s · %s", s.ModTime.Local().Format("01-02 15:04"), sessionSummary(s))
out = append(out, compItem{label: idx, insert: idx, hint: hint})
}
return out, from, true
}
// sessionSummary is the "N turns · display title" line shared by the /resume
// list and its argument completion. Explicit session renames win, then topic
// titles, then the raw preview so the user can identify sessions at a glance.
func sessionSummary(s agent.SessionInfo) string {
preview := s.CustomTitle
if preview == "" {
preview = s.TopicTitle
}
if preview == "" {
preview = s.Preview
}
if preview == "" {
preview = "(no user message yet)"
}
return fmt.Sprintf("%d turns · %s", s.Turns, preview)
}