feat: interactive session picker for /resume and --resume
This commit is contained in:
@@ -417,6 +417,7 @@ func main() {
|
||||
|
||||
// Resume logic: --resume/-r flag
|
||||
resumedTurnCount := 0
|
||||
openResumePicker := false
|
||||
resumeRequested := isFlagSet("resume") || isFlagSet("r")
|
||||
if resumeRequested {
|
||||
var snap session.Snapshot
|
||||
@@ -425,6 +426,11 @@ func main() {
|
||||
snap, loadErr = sessStore.Load(resumeFlag)
|
||||
}
|
||||
if resumeFlag == "" || loadErr != nil {
|
||||
// No specific ID given (or ID not found): open interactive picker in TUI,
|
||||
// or fall back to text list in pipe mode.
|
||||
if isTUI {
|
||||
openResumePicker = true
|
||||
} else {
|
||||
sessions, listErr := sessStore.List()
|
||||
if listErr != nil || len(sessions) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "no saved sessions found")
|
||||
@@ -443,6 +449,7 @@ func main() {
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
} else {
|
||||
// Valid session found — restore engine state
|
||||
eng.SetHistory(snap.Messages)
|
||||
eng.SetUsage(snap.Metadata.Usage)
|
||||
@@ -450,6 +457,7 @@ func main() {
|
||||
resumedTurnCount = snap.Metadata.TurnCount
|
||||
logger.Info("session resumed", "id", snap.ID, "turns", snap.Metadata.TurnCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect mode: TUI (interactive TTY) or pipe mode
|
||||
input, err := readInput(flag.Args())
|
||||
@@ -530,6 +538,7 @@ func main() {
|
||||
PermReqCh: permReqCh,
|
||||
ElfProgress: elfProgressCh,
|
||||
SessionStore: sessStore,
|
||||
StartWithResumePicker: openResumePicker,
|
||||
})
|
||||
p := tea.NewProgram(m)
|
||||
if _, err := p.Run(); err != nil {
|
||||
|
||||
@@ -35,13 +35,16 @@ const version = "v0.1.0-dev"
|
||||
|
||||
type streamEventMsg struct{ event stream.Event }
|
||||
type turnDoneMsg struct{ err error }
|
||||
|
||||
// PermReqMsg carries a permission request from engine to TUI.
|
||||
type PermReqMsg struct {
|
||||
ToolName string
|
||||
Args json.RawMessage
|
||||
}
|
||||
|
||||
type elfProgressMsg struct{ progress elf.Progress }
|
||||
type clearQuitHintMsg struct{}
|
||||
type resumeListLoadedMsg struct{ sessions []session.Metadata }
|
||||
|
||||
type chatMessage struct {
|
||||
role string
|
||||
@@ -59,6 +62,7 @@ type Config struct {
|
||||
PermReqCh <-chan PermReqMsg // engine → TUI: tool requesting approval
|
||||
ElfProgress <-chan elf.Progress // elf → TUI: structured progress updates
|
||||
SessionStore *session.SessionStore // nil = no persistence
|
||||
StartWithResumePicker bool // open session picker on launch
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
@@ -89,6 +93,11 @@ type Model struct {
|
||||
permPending bool // waiting for user to approve/deny a tool
|
||||
permToolName string // which tool is asking
|
||||
permArgs json.RawMessage // tool args for display
|
||||
|
||||
// Session resume picker
|
||||
resumePending bool
|
||||
resumeSessions []session.Metadata
|
||||
resumeSelected int
|
||||
initPending bool // true while /init turn is in-flight; triggers AGENTS.md reload on turnDone
|
||||
initHadToolCalls bool // set when any tool call fires during an init turn
|
||||
initRetried bool // set after first retry (no-tool-call case) so we don't retry indefinitely
|
||||
@@ -145,7 +154,18 @@ func New(sess session.Session, cfg Config) Model {
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.input.Focus()
|
||||
cmds := []tea.Cmd{m.input.Focus()}
|
||||
if m.config.StartWithResumePicker && m.config.SessionStore != nil {
|
||||
store := m.config.SessionStore
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
sessions, err := store.List()
|
||||
if err != nil || len(sessions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return resumeListLoadedMsg{sessions: sessions}
|
||||
})
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -168,6 +188,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Escape = global stop, never quits
|
||||
if msg.String() == "escape" {
|
||||
if m.resumePending {
|
||||
m.resumePending = false
|
||||
m.resumeSessions = nil
|
||||
m.resumeSelected = 0
|
||||
return m, nil
|
||||
}
|
||||
if m.permPending {
|
||||
m.permPending = false
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
@@ -232,6 +258,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil // ignore other keys while prompting
|
||||
}
|
||||
|
||||
// --- Session picker (only when resume picker is open) ---
|
||||
if m.resumePending {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if m.resumeSelected > 0 {
|
||||
m.resumeSelected--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.resumeSelected < len(m.resumeSessions)-1 {
|
||||
m.resumeSelected++
|
||||
}
|
||||
case "enter":
|
||||
return m.confirmResumeSelection()
|
||||
}
|
||||
return m, nil // swallow all other keys
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+x":
|
||||
// Toggle incognito
|
||||
@@ -319,6 +362,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.quitHint = false
|
||||
return m, nil
|
||||
|
||||
case resumeListLoadedMsg:
|
||||
if len(msg.sessions) > 0 {
|
||||
m.resumePending = true
|
||||
m.resumeSessions = msg.sessions
|
||||
m.resumeSelected = 0
|
||||
m.scrollOffset = 0
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case elfProgressMsg:
|
||||
p := msg.progress
|
||||
// Keep completed elfs in tree — only cleared on turnDoneMsg
|
||||
@@ -734,50 +786,27 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
|
||||
m.messages = append(m.messages, chatMessage{role: "system", content: "session persistence is not configured"})
|
||||
return m, nil
|
||||
}
|
||||
if args != "" {
|
||||
snap, loadErr := m.config.SessionStore.Load(args)
|
||||
if loadErr == nil {
|
||||
return m.applySessionSnapshot(snap)
|
||||
}
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: fmt.Sprintf("session %q not found", args)})
|
||||
}
|
||||
sessions, err := m.config.SessionStore.List()
|
||||
if err != nil {
|
||||
m.messages = append(m.messages, chatMessage{role: "error", content: "failed to list sessions: " + err.Error()})
|
||||
return m, nil
|
||||
}
|
||||
if args != "" {
|
||||
snap, loadErr := m.config.SessionStore.Load(args)
|
||||
if loadErr == nil {
|
||||
if m.config.Engine != nil {
|
||||
m.config.Engine.SetHistory(snap.Messages)
|
||||
m.config.Engine.SetUsage(snap.Metadata.Usage)
|
||||
}
|
||||
// Rebuild display history from restored messages (text only)
|
||||
m.messages = nil
|
||||
for _, msg := range snap.Messages {
|
||||
if t := msg.TextContent(); t != "" {
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: string(msg.Role),
|
||||
content: t,
|
||||
})
|
||||
}
|
||||
}
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: fmt.Sprintf("Session %s resumed (%d turns, %s/%s)",
|
||||
snap.ID, snap.Metadata.TurnCount, snap.Metadata.Provider, snap.Metadata.Model)})
|
||||
return m, nil
|
||||
}
|
||||
// Session not found — fall through to show list with error note
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: fmt.Sprintf("session %q not found — available sessions:", args)})
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
m.messages = append(m.messages, chatMessage{role: "system", content: "no saved sessions"})
|
||||
return m, nil
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Saved sessions:\n\n")
|
||||
for _, s := range sessions {
|
||||
fmt.Fprintf(&b, " %s %s/%s %d turns %s\n",
|
||||
s.ID, s.Provider, s.Model, s.TurnCount,
|
||||
s.UpdatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
b.WriteString("\nUse /resume <id> to restore a session.")
|
||||
m.messages = append(m.messages, chatMessage{role: "system", content: b.String()})
|
||||
m.resumePending = true
|
||||
m.resumeSessions = sessions
|
||||
m.resumeSelected = 0
|
||||
m.scrollOffset = 0
|
||||
return m, nil
|
||||
|
||||
case "/help":
|
||||
@@ -971,6 +1000,24 @@ func (m Model) renderChat(height int) string {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Transient: session resume picker
|
||||
if m.resumePending && len(m.resumeSessions) > 0 {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, sSystem.Render(" Sessions ↑↓ · Enter to load · Esc to cancel"))
|
||||
lines = append(lines, "")
|
||||
for i, s := range m.resumeSessions {
|
||||
age := time.Since(s.UpdatedAt).Truncate(time.Minute)
|
||||
row := fmt.Sprintf("%-26s %s/%s %d turns %s ago",
|
||||
s.ID, s.Provider, s.Model, s.TurnCount, age)
|
||||
if i == m.resumeSelected {
|
||||
lines = append(lines, sText.Render("→ "+row))
|
||||
} else {
|
||||
lines = append(lines, sHint.Render(" "+row))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Streaming: show frozen thinking above live text content
|
||||
if m.streaming {
|
||||
maxWidth := m.width - 2
|
||||
@@ -1420,6 +1467,47 @@ func isLocalProvider(providerName string) bool {
|
||||
return providerName == "ollama" || providerName == "llamacpp"
|
||||
}
|
||||
|
||||
// confirmResumeSelection loads the currently highlighted session and restores it.
|
||||
func (m Model) confirmResumeSelection() (tea.Model, tea.Cmd) {
|
||||
if m.resumeSelected < 0 || m.resumeSelected >= len(m.resumeSessions) {
|
||||
m.resumePending = false
|
||||
return m, nil
|
||||
}
|
||||
selected := m.resumeSessions[m.resumeSelected]
|
||||
m.resumePending = false
|
||||
m.resumeSessions = nil
|
||||
m.resumeSelected = 0
|
||||
snap, err := m.config.SessionStore.Load(selected.ID)
|
||||
if err != nil {
|
||||
m.messages = append(m.messages, chatMessage{role: "error",
|
||||
content: fmt.Sprintf("failed to load session %q: %v", selected.ID, err)})
|
||||
return m, nil
|
||||
}
|
||||
return m.applySessionSnapshot(snap)
|
||||
}
|
||||
|
||||
// applySessionSnapshot restores engine state from a snapshot and rebuilds the display history.
|
||||
func (m Model) applySessionSnapshot(snap session.Snapshot) (tea.Model, tea.Cmd) {
|
||||
if m.config.Engine != nil {
|
||||
m.config.Engine.SetHistory(snap.Messages)
|
||||
m.config.Engine.SetUsage(snap.Metadata.Usage)
|
||||
}
|
||||
m.messages = nil
|
||||
for _, msg := range snap.Messages {
|
||||
if t := msg.TextContent(); t != "" {
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: string(msg.Role),
|
||||
content: t,
|
||||
})
|
||||
}
|
||||
}
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: fmt.Sprintf("Session %s resumed (%d turns, %s/%s)",
|
||||
snap.ID, snap.Metadata.TurnCount, snap.Metadata.Provider, snap.Metadata.Model)})
|
||||
m.scrollOffset = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// reModelCodeBlock matches <<tool_code>>…<</tool_code>> blocks that some models
|
||||
// (e.g. Gemma4) emit as plain text instead of structured function calls.
|
||||
var reModelCodeBlock = regexp.MustCompile(`(?s)(<<[/]?tool_code>>.*?<<[/]tool_code>>|<<function_call>>.*?<tool_call\|>)`)
|
||||
|
||||
Reference in New Issue
Block a user