package tui import ( "fmt" "path/filepath" "strings" "time" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/johannSo/codey/internal/agent" "github.com/johannSo/codey/internal/llm" ) // The session chooser is deliberately scoped to the working root. Sessions are // already filed under /.codey/sessions, and every row is checked against // the root a second time on the way out of the agent package — a conversation // about one checkout has no business being reopened in another, where none of // the paths it discusses exist. // openSessionPicker reads the sessions belonging to this root and puts the // chooser up. The list comes off local disk rather than out of the network, so // it is read here and not in a command: there is no wait to render. func (m *model) openSessionPicker() tea.Cmd { if why, ok := m.sessionSwitchBlocked(); !ok { m.tr.addError(why) return m.sync() } list, err := agent.ListSessions(m.opts.SessionDir, m.opts.Root) if err != nil { m.tr.addError("could not read saved sessions from " + m.opts.SessionDir + ": " + err.Error()) return m.sync() } if len(list) == 0 { m.tr.addNotice("no saved sessions for " + shortenHome(m.opts.Root) + " — one is written after the first turn, and only this directory's are offered") return m.sync() } m.sessions = list m.sessionIdx = sessionIndex(list, m.opts.SessionID) m.phase = phaseSessions m.closeMenu() return m.sync() } // sessionSwitchBlocked says why the conversation cannot be swapped out right // now. Switching replaces the history the agent is building on, so anything // mid-flight has to finish or be interrupted first. func (m *model) sessionSwitchBlocked() (string, bool) { switch { case m.opts.Agent == nil: return "no agent to resume into", false case m.phase == phaseRunning: return "/resume would replace the history the turn is building on — interrupt first", false case m.compacting: return "/resume would replace the history being compacted — interrupt first", false case m.worktreeing: return "/resume would land in the middle of moving to a worktree — interrupt first", false } return "", true } // sessionIndex opens the chooser on the session in force, so the list says // where you already are rather than starting at the top. func sessionIndex(list []agent.SessionInfo, current string) int { for i, s := range list { if s.ID == current { return i } } return 0 } // onSessionKey drives the chooser. func (m *model) onSessionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if key == "esc" || key == "ctrl+c" { m.phase, m.sessions = phaseIdle, nil return m, tea.Batch(m.sync(), textarea.Blink) } if len(m.sessions) == 0 { return m, nil } last := len(m.sessions) - 1 switch key { case "up", "ctrl+p": m.sessionIdx = maxInt(0, m.sessionIdx-1) case "down", "ctrl+n": m.sessionIdx = minInt(last, m.sessionIdx+1) case "home": m.sessionIdx = 0 case "end": m.sessionIdx = last case "enter", "tab": id := m.sessions[m.sessionIdx].ID m.phase, m.sessions = phaseIdle, nil m.resumeSession(id) return m, tea.Batch(m.sync(), textarea.Blink) default: if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { if i := int(key[0] - '1'); i <= last { m.sessionIdx = i } } } return m, m.sync() } // resumeSession loads a session by id and switches to it, refusing anything // that was not recorded in this working root. func (m *model) resumeSession(id string) { if why, ok := m.sessionSwitchBlocked(); !ok { m.tr.addError(why) return } if id == m.opts.SessionID { m.tr.addNotice("already in session " + id) return } sess, err := agent.LoadSession(filepath.Join(m.opts.SessionDir, id+".json")) if err != nil { m.tr.addError("no session " + id + " in " + shortenHome(m.opts.SessionDir) + " — /resume lists this directory's") return } if !sess.BelongsTo(m.opts.Root) { m.tr.addError("session " + id + " belongs to " + shortenHome(sess.Root) + ", not " + shortenHome(m.opts.Root) + " — resume it from there") return } m.applySession(sess) } // applySession swaps the conversation over: the agent's history, the session // the next turn is saved into, and the scrollback, which is replayed from the // messages so the transcript shows the conversation being resumed rather than // an empty screen above it. func (m *model) applySession(s agent.Session) { // The session being left is written out first. A turn already saves on the // way out, but anything a subagent filed since then, or a compaction, has // not been — and this is the last moment it can be. if m.opts.SessionID != "" && m.turns > 0 { if err := m.saveSession(); err != nil { m.tr.addError("could not save session " + m.opts.SessionID + ": " + err.Error()) } } m.opts.Agent.Restore(s) m.opts.SessionID = s.ID // The tab is named after the conversation in it, so resuming one renames // the tab it lands in. m.title = firstLine(s.Title()) // The restored totals are what the session cost, including what earlier // runs already paid for; priced is set to match so the next turn is billed // on its own delta rather than on the whole history a second time. m.usage, m.turns, m.priced = s.Usage, s.Turns, s.Usage // What the model last read is unknown until it reads it, so the context // gauge waits for the next response rather than reporting the old one's. m.ctxTokens = 0 m.clearSelection() m.tr.clear() m.replay(s.Messages) title := s.Title() if title == "" { title = "no prompt yet" } m.tr.addNotice(fmt.Sprintf("resumed %s — %s (%s, %s)", s.ID, title, plural(s.Turns, "turn"), plural(len(s.Messages), "message"))) if s.Model != "" && s.Model != m.opts.Client.Model() { m.tr.addNotice("it ran on " + prettyModel(s.Model) + "; this session continues on " + prettyModel(m.opts.Client.Model()) + " — /model switches") } } // sessionTitle is what a saved session was about, for naming the tab it is // opened in. A session that will not load has no title and no complaint: this // is a label, and the conversation itself is where a broken file is reported. func sessionTitle(dir, id string) string { if dir == "" || id == "" { return "" } s, err := agent.LoadSession(filepath.Join(dir, id+".json")) if err != nil { return "" } return firstLine(s.Title()) } // replay rebuilds the scrollback from a saved conversation. It is a reading of // the history, not a recording of what was on screen: thinking blocks and the // exact streaming are gone, but what was said and what was run is all there. func (m *model) replay(msgs []llm.Message) { // Tool results arrive in the following user message, so they are collected // first and then shown with the call they answer. results := map[string]llm.ContentBlock{} for _, msg := range msgs { for _, b := range msg.Content { if b.Type == "tool_result" { results[b.ToolUseID] = b } } } for _, msg := range msgs { for _, b := range msg.Content { switch b.Type { case "text", "": if strings.TrimSpace(b.Text) == "" { continue } if msg.Role == "user" { m.tr.addUser(b.Text) } else { m.tr.add(entry{kind: entryAssistant, text: b.Text}) } case "tool_use": m.tr.startTool(b.Name, string(b.Input)) res, ok := results[b.ID] if !ok { // A call whose result never came back: the session was // interrupted between the two. m.tr.finishTool(b.Name, "(no result recorded)", false) continue } m.tr.finishTool(b.Name, res.Content, res.IsError) } } } m.tr.closeOpen() } // sessionView is the chooser: a heading, the directory the list is scoped to, // and a row per session with when it was last touched and what it was about. func (m *model) sessionView() string { width := maxInt(1, m.mainWidth()) head := " " + styHeading.Render("Resume session") from := styDim.Render("in " + shortenHome(m.opts.Root)) start, end := m.sessionWindow() // A column for the ages and a column for the titles, so what each session // was and what it cost line up under each other rather than starting // wherever the row before them happened to end. ages := make([]string, len(m.sessions)) titles := make([]string, len(m.sessions)) ageCol, titleCol := 0, 0 for i, s := range m.sessions { ages[i] = ago(time.Since(s.Updated)) titles[i] = s.Title if titles[i] == "" { titles[i] = styFaint.Render("(no prompt)") } if i >= start && i < end { ageCol = maxInt(ageCol, lipgloss.Width(ages[i])) titleCol = maxInt(titleCol, lipgloss.Width(titles[i])) } } titleCol = minInt(titleCol, maxInt(20, width/2)) var b strings.Builder b.WriteString(head + "\n") b.WriteString(truncateStyled(" "+from+styFaint.Render(" · ↑↓ move · enter resume · esc cancel"), width) + "\n") for i := start; i < end; i++ { s := m.sessions[i] marker, age := " ", styDim.Render(pad(ages[i], ageCol)) if i == m.sessionIdx { marker, age = styKey.Render("❯ "), styKey.Render(pad(ages[i], ageCol)) } detail := plural(s.Turns, "turn") if s.Model != "" { detail += " · " + prettyModel(s.Model) } title := pad(truncateStyled(titles[i], titleCol), titleCol) line := marker + fmt.Sprintf("%d. ", i+1) + age + " " + title + " " + styFaint.Render(detail) if s.ID == m.opts.SessionID { line += styOK.Render(" ✓") } b.WriteString(truncateStyled(" "+line, width) + "\n") } if n := len(m.sessions) - (end - start); n > 0 { b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), width) + "\n") } return strings.TrimRight(b.String(), "\n") } // sessionHeadRows is how many rows come before the list: the heading, and the // line saying which directory the sessions are from. const sessionHeadRows = 2 // sessionWindow is the slice of the list that is on screen, shared by the view // and by the hit test, so a click lands on the session it looks like it is on. func (m *model) sessionWindow() (start, end int) { rows := m.sessionRows() shown := rows if len(m.sessions) > rows { shown = maxInt(1, rows-1) } if m.sessionIdx >= shown { start = m.sessionIdx - shown + 1 } return start, minInt(len(m.sessions), start+shown) } // sessionRows is how many session rows fit, by the same arithmetic the model // picker does. func (m *model) sessionRows() int { room := m.height - 6 return maxInt(1, minInt(sessionMaxRows, room)) } // sessionMaxRows caps the list. Beyond a dozen, scanning stops working and the // id is the faster way in — which /resume is for. const sessionMaxRows = 12 // ago says how long ago something was in the shape a list wants: short, and // coarser the further back it goes, because nobody picks a session by the // minute it was saved three weeks later. func ago(d time.Duration) string { switch { case d < 0: return "just now" case d < time.Minute: return "just now" case d < time.Hour: return fmt.Sprintf("%dm ago", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%dh ago", int(d.Hours())) case d < 7*24*time.Hour: return fmt.Sprintf("%dd ago", int(d.Hours()/24)) } return fmt.Sprintf("%dw ago", int(d.Hours()/24/7)) }