package tui
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"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"
)
// /worktree resume is the other half of /worktree: one makes a tree, this one
// goes back to a tree that already exists. What it opens there is a new chat,
// not the old one — a worktree outlives the conversation that made it, and the
// conversation you want in it a week later is rarely the one you left behind.
// (/resume is still how you pick up a specific conversation; a session that had
// moved into a tree takes you back to it.)
// resumeWord is the one argument /worktree does not read as a name. A tree
// called "resume" has to be made by hand, which is a price worth paying for a
// subcommand you can guess.
const resumeWord = "resume"
// worktreeListTimeout bounds the git call behind the picker. Listing trees is
// metadata only and returns in milliseconds; anything longer is a repository
// that is not going to answer, and the picker should say so rather than hang.
const worktreeListTimeout = 5 * time.Second
// worktreeInfo is one tree of this repository: where it is, what it is called,
// and what is checked out in it.
type worktreeInfo struct {
Name string
Path string
Branch string
// Main marks the checkout Codey was started in, which git lists alongside
// the trees made from it. It is offered too, because once the work has
// moved into a tree there has to be a way back.
Main bool
// Touched is the tree directory's own timestamp. It is only used to put
// the trees in a sensible order and to say roughly how stale each one is.
Touched time.Time
}
// resumeWorktree answers /worktree resume. Bare, it opens the chooser; with a
// name, it goes straight there, which is the faster path once you know what
// the tree is called.
func (m *model) resumeWorktree(name string) tea.Cmd {
if why, ok := m.worktreeSwitchBlocked(); !ok {
m.tr.addError(why)
return m.sync()
}
trees, err := listWorktrees(m.parent, m.opts.HomeRoot)
if err != nil {
m.tr.addError("could not list the worktrees of " + shortenHome(m.opts.HomeRoot) + ": " + err.Error())
return m.sync()
}
// git always reports the checkout itself, so "nothing to offer" means
// nothing but where the work already is — a one-row chooser saying "here"
// is not a choice.
if len(trees) == 0 || (len(trees) == 1 && samePath(trees[0].Path, m.opts.Root)) {
m.tr.addNotice("no other worktree of " + shortenHome(m.opts.HomeRoot) +
" — /worktree <name> makes one")
return m.sync()
}
if name != "" {
w, ok := findWorktree(trees, name)
if !ok {
m.tr.addError("no worktree called " + name + " — there is " + worktreeNames(trees))
return m.sync()
}
m.openWorktreeChat(w)
return m.sync()
}
m.worktrees = trees
m.worktreeFilter = ""
m.refilterWorktrees()
m.worktreeIdx = worktreeIndex(m.worktreeShown, m.opts.Root)
m.phase = phaseWorktrees
m.closeMenu()
return m.sync()
}
// worktreeSwitchBlocked says why the work cannot move right now. Moving
// rebuilds every tool against another directory and empties the conversation,
// so anything in flight has to finish or be interrupted first — the same rule
// /worktree and /resume already keep.
func (m *model) worktreeSwitchBlocked() (string, bool) {
switch {
case m.opts.NewTools == nil:
return "/worktree resume is not available in this session — it has no way to move the tools", false
case m.opts.Agent == nil:
return "no agent to open a chat in", false
case m.phase == phaseRunning:
return "/worktree resume would move the ground under the running turn — interrupt first", false
case m.compacting:
return "/worktree resume would replace the history being compacted — interrupt first", false
case m.worktreeing:
return "/worktree resume would land in the middle of making a worktree — interrupt first", false
}
return "", true
}
// findWorktree resolves what was typed after "resume": a name, or a path.
func findWorktree(trees []worktreeInfo, want string) (worktreeInfo, bool) {
for _, w := range trees {
if w.Name == want || w.Path == want || w.Branch == want {
return w, true
}
}
// A slug is what /worktree would have made of it, so "dark mode" finds the
// tree "dark-mode" rather than being refused over a space.
if slug := slugify(want); slug != "" {
for _, w := range trees {
if w.Name == slug {
return w, true
}
}
}
return worktreeInfo{}, false
}
// worktreeNames lists what there is, for the error raised when a name misses.
func worktreeNames(trees []worktreeInfo) string {
names := make([]string, 0, len(trees))
for _, w := range trees {
names = append(names, w.Name)
}
return strings.Join(names, ", ")
}
// worktreeIndex opens the chooser on the tree the work is already in, so the
// list says where you are rather than starting at the top.
func worktreeIndex(trees []worktreeInfo, root string) int {
for i, w := range trees {
if samePath(w.Path, root) {
return i
}
}
return 0
}
func samePath(a, b string) bool {
if a == "" || b == "" {
return false
}
return filepath.Clean(a) == filepath.Clean(b)
}
// refilterWorktrees recomputes what the picker is showing. The filtered list is
// held rather than derived at each use, so the keys, the view and a mouse click
// are all looking at the same rows.
func (m *model) refilterWorktrees() {
m.worktreeShown = fuzzyRank(m.worktrees, m.worktreeFilter, worktreeHaystack)
m.worktreeIdx = maxInt(0, minInt(m.worktreeIdx, len(m.worktreeShown)-1))
}
// worktreeHaystack is what the filter matches against: the name, and the
// branch when it says something the name does not. Typing part of a branch is
// as natural a way to find a tree as typing part of its directory.
func worktreeHaystack(w worktreeInfo) string {
if w.Branch != "" && w.Branch != w.Name {
return w.Name + " " + w.Branch
}
return w.Name
}
// onWorktreeKey drives the chooser. Unlike the other pickers this one takes
// typing, so there are no digit shortcuts: a number is a character of the
// filter, which is what a filter is for.
func (m *model) onWorktreeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "esc", "ctrl+c":
m.closeWorktreePicker()
return m, tea.Batch(m.sync(), textarea.Blink)
case "up", "ctrl+p":
m.worktreeIdx = maxInt(0, m.worktreeIdx-1)
case "down", "ctrl+n":
m.worktreeIdx = minInt(len(m.worktreeShown)-1, m.worktreeIdx+1)
case "home":
m.worktreeIdx = 0
case "end":
m.worktreeIdx = maxInt(0, len(m.worktreeShown)-1)
case "enter", "tab":
if len(m.worktreeShown) == 0 {
return m, nil
}
w := m.worktreeShown[m.worktreeIdx]
m.closeWorktreePicker()
m.openWorktreeChat(w)
return m, tea.Batch(m.sync(), textarea.Blink)
case "backspace":
if r := []rune(m.worktreeFilter); len(r) > 0 {
m.worktreeFilter = string(r[:len(r)-1])
m.refilterWorktrees()
}
case "ctrl+u":
m.worktreeFilter = ""
m.refilterWorktrees()
case "space":
// Bubble Tea reports the space bar by name, not as a rune.
m.worktreeFilter += " "
m.refilterWorktrees()
default:
if msg.Type == tea.KeyRunes && !msg.Alt {
m.worktreeFilter += string(msg.Runes)
m.refilterWorktrees()
}
}
return m, m.sync()
}
// closeWorktreePicker puts the prompt back and drops the list.
func (m *model) closeWorktreePicker() {
m.phase = phaseIdle
m.worktrees, m.worktreeShown, m.worktreeFilter, m.worktreeIdx = nil, nil, "", 0
}
// openWorktreeChat moves the work into an existing tree and starts a fresh
// conversation there. The session being left is written out first: the new one
// is filed under the same home root, with the tree recorded on it, so /resume
// still lists both and reopening either comes back to the right directory.
func (m *model) openWorktreeChat(w worktreeInfo) {
if why, ok := m.worktreeSwitchBlocked(); !ok {
m.tr.addError(why)
return
}
if fi, err := os.Stat(w.Path); err != nil || !fi.IsDir() {
m.tr.addError("the worktree " + shortenHome(w.Path) + " is gone — git worktree prune tidies the record")
return
}
// A turn already saves on the way out, but a compaction or a subagent's
// report since then 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())
}
}
moved := !samePath(w.Path, m.opts.Root)
m.switchRoot(w.Path)
m.newChat()
where := "working in " + shortenHome(w.Path)
if !moved {
where = "already in " + shortenHome(w.Path)
}
if w.Branch != "" {
where += " on " + w.Branch
}
m.tr.addNotice(where + " — new chat " + m.opts.SessionID +
", filed in " + shortenHome(m.opts.HomeRoot) + " like every other")
}
// newChat empties the conversation and files whatever follows under a new
// session. The old one is left on disk untouched, so /resume can go back to it.
//
// Nothing is written for the new session yet: an empty file would be a row in
// every future chooser standing for a conversation that never happened. The
// first turn saves it, tree and all.
func (m *model) newChat() {
m.opts.Agent.Restore(agent.Session{})
m.opts.SessionID = newSessionID()
// The bill for the process carries on; what this session has spent starts
// again from nothing, because it has said nothing yet.
m.usage, m.turns, m.priced = llm.Usage{}, 0, llm.Usage{}
m.ctxTokens = 0
m.clearSelection()
m.tr.clear()
m.tr.addRaw(splash(m.opts, maxInt(20, m.mainWidth())))
}
// newSessionID names a session the way main.go does, by the second it began.
// Sessions are listed by their id, so a name that sorts is worth more than a
// name that is unique to the microsecond.
func newSessionID() string { return time.Now().UTC().Format("20060102T150405Z") }
// listWorktrees asks git what trees this repository has. git is the authority
// rather than the worktrees directory on disk: the script that makes them is
// the user's own and may put them anywhere, and a directory sitting in that
// folder from some other checkout is not this repository's business.
func listWorktrees(ctx context.Context, repoRoot string) ([]worktreeInfo, error) {
ctx, cancel := context.WithTimeout(ctx, worktreeListTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "worktree", "list", "--porcelain")
cmd.Dir = repoRoot
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if detail := lastLine(stderr.String()); detail != "" {
return nil, fmt.Errorf("%s", detail)
}
return nil, err
}
return parseWorktreeList(string(out)), nil
}
// parseWorktreeList reads git's porcelain format: a stanza per tree, keyed
// lines within it, blank line between. Only the path and the branch are of any
// use here; a bare repository has no working tree to move into and is dropped,
// as is a tree whose directory has been deleted without git being told.
func parseWorktreeList(out string) []worktreeInfo {
var trees []worktreeInfo
var cur worktreeInfo
var bare, first bool
first = true
flush := func() {
if cur.Path == "" {
cur, bare = worktreeInfo{}, false
return
}
// git lists the main checkout first, and it is the only one that is
// not a worktree made from something else. The position is claimed by
// whatever git listed there even when we drop it — a bare repository
// is listed first and is nobody's checkout, and a checkout whose
// directory has been deleted is still the one that was first. Without
// this the tree after it would inherit a title that belongs to
// nothing, and be pinned to the top of the chooser calling itself the
// way home.
wasFirst := first
first = false
if bare {
cur, bare = worktreeInfo{}, false
return
}
fi, err := os.Stat(cur.Path)
if err != nil || !fi.IsDir() {
cur, bare = worktreeInfo{}, false
return
}
cur.Name = filepath.Base(cur.Path)
cur.Touched = fi.ModTime()
cur.Main = wasFirst
trees = append(trees, cur)
cur, bare = worktreeInfo{}, false
}
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
if strings.TrimSpace(line) == "" {
flush()
continue
}
key, value, _ := strings.Cut(line, " ")
switch key {
case "worktree":
cur.Path = value
case "branch":
cur.Branch = strings.TrimPrefix(value, "refs/heads/")
case "detached":
cur.Branch = "detached"
case "bare":
bare = true
}
}
flush()
// Most recently touched first, which is nearly always the order someone is
// looking in — except for the main checkout, which stays at the top as the
// way home rather than drifting about by its own mtime.
sort.SliceStable(trees, func(i, j int) bool {
if trees[i].Main != trees[j].Main {
return trees[i].Main
}
return trees[i].Touched.After(trees[j].Touched)
})
return trees
}
// worktreeView is the chooser: a heading, what has been typed to narrow the
// list, and a row per tree with its name, how long since it was touched, and
// what is checked out in it.
func (m *model) worktreeView() string {
width := maxInt(1, m.mainWidth())
head := " " + styHeading.Render("Open a worktree")
from := styDim.Render("of " + shortenHome(m.opts.HomeRoot))
// The filter stands where the invitation to type it was, so the line does
// not grow as it is used.
if m.worktreeFilter == "" {
from += styFaint.Render(" · type to filter")
} else {
from += styFaint.Render(" · ") + styKey.Render(m.worktreeFilter+"▏")
}
hint := " · ↑↓ move · enter new chat · esc cancel"
var b strings.Builder
b.WriteString(head + "\n")
b.WriteString(truncateStyled(" "+from+styFaint.Render(hint), width) + "\n")
if len(m.worktreeShown) == 0 {
return b.String() + truncateStyled(" "+styFaint.Render("nothing matches "+m.worktreeFilter), width)
}
start, end := m.worktreeWindow()
// A column for the names and one for the ages, so what each tree is and
// when it was last touched line up under each other.
names := make([]string, len(m.worktreeShown))
ages := make([]string, len(m.worktreeShown))
nameCol, ageCol := 0, 0
for i, w := range m.worktreeShown {
names[i], ages[i] = w.Name, ago(time.Since(w.Touched))
if i >= start && i < end {
nameCol = maxInt(nameCol, lipgloss.Width(names[i]))
ageCol = maxInt(ageCol, lipgloss.Width(ages[i]))
}
}
nameCol = minInt(nameCol, maxInt(12, width/3))
for i := start; i < end; i++ {
w := m.worktreeShown[i]
marker, name := " ", styDim.Render(pad(truncateStyled(names[i], nameCol), nameCol))
if i == m.worktreeIdx {
marker, name = styKey.Render("❯ "), styKey.Render(pad(truncateStyled(names[i], nameCol), nameCol))
}
detail := w.Branch
if w.Main {
// What it is comes before what is checked out in it: the way home
// is the thing worth spotting in the list.
detail = strings.TrimSuffix("main checkout · "+detail, " · ")
}
line := marker + name + " " + styDim.Render(pad(ages[i], ageCol)) + " " + styFaint.Render(detail)
if samePath(w.Path, m.opts.Root) {
line += styOK.Render(" ✓")
}
b.WriteString(truncateStyled(" "+line, width) + "\n")
}
if n := len(m.worktreeShown) - (end - start); n > 0 {
b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), width) + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
// worktreeHeadRows is how many rows come before the list: the heading, and the
// line carrying the filter.
const worktreeHeadRows = 2
// worktreeWindow 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 tree it looks like it is on.
func (m *model) worktreeWindow() (start, end int) {
rows := m.worktreeRows()
shown := rows
if len(m.worktreeShown) > rows {
shown = maxInt(1, rows-1)
}
if m.worktreeIdx >= shown {
start = m.worktreeIdx - shown + 1
}
return start, minInt(len(m.worktreeShown), start+shown)
}
// worktreeRows is how many tree rows fit, by the same arithmetic the session
// picker does.
func (m *model) worktreeRows() int {
room := m.height - 6
return maxInt(1, minInt(worktreeMaxRows, room))
}
// worktreeMaxRows caps the list. Past a dozen the filter is the faster way in,
// which is what it is there for.
const worktreeMaxRows = 12