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" "codey/internal/agent" "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 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