Vectrum
Sign In
481 lines · 14.8 KB · Go
package tui

import (
	"context"
	"fmt"
	"strings"

	"github.com/charmbracelet/bubbles/textarea"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

// Tabs are several conversations open at once in one terminal. Each one is a
// whole model — its own agent, its own history, its own approvals, its own
// session file — and exactly one of them is on screen. The strip at the top
// says which, and which of the others have been getting on with something.
//
// The split of work is: a model knows nothing about tabs beyond the id it
// stamps on the messages it sends itself, and this file knows nothing about
// what is inside a conversation beyond where to deliver a message and how big
// to make it. That is what keeps the single-tab case exactly what it was —
// with one tab open there is no strip, no lost row, and every message travels
// the same path it did before tabs existed.

// maxTabs caps the strip. Nine is where the alt+<n> shortcuts run out, and
// well past where a row of names still reads as a row of names.
const maxTabs = 9

// forTab is a message addressed to one conversation rather than to whichever
// is on screen. An agent goes on working in a tab you have left, and its
// events, its finished turn and its approval requests all have to arrive in
// the conversation that asked for them.
type forTab struct {
	id  int
	msg tea.Msg
}

// newTabMsg asks for another conversation. It exists so that /new can do what
// ctrl+t does: a model cannot open a tab itself — it has no idea there are
// any — so it says what it wants and the strip does it.
type newTabMsg struct{}

// closeTabMsg asks for the conversation on screen to be closed, which is what
// /close does.
type closeTabMsg struct{}

// tab is one conversation in the strip.
type tab struct {
	m *model
	// unseen marks a tab that said something while you were in another one.
	// It is cleared by looking at it.
	unseen bool
	// close releases what the session holds — its subagent supervisor above
	// all. Nil for the first tab, whose pieces the caller owns and shuts down
	// itself when Run returns.
	close func()
}

// tabs is the outer model: the strip, and the conversations under it.
type tabs struct {
	// base is the Options the run started with. A tab opened later is built
	// from it with the per-session pieces replaced, so everything that is
	// about this run rather than about one conversation — the client, the
	// config, the skills, the version — is the same in every tab.
	base Options
	ctx  context.Context

	list   []*tab
	active int
	// last is the highest id handed out. Ids are never reused: a message from
	// a closed tab has to be dropped, not delivered to whatever took its slot.
	last int

	p             sender
	width, height int
}

// newTabs wraps the first conversation in a strip. Nothing is drawn for it
// until a second one is opened.
func newTabs(ctx context.Context, opts Options) *tabs {
	t := &tabs{base: opts, ctx: ctx, last: 1}
	first := newModel(ctx, opts)
	first.id = 1
	first.bridge.tab = 1
	if opts.SessionID != "" {
		// A resumed session arrives with a history behind it; name the tab
		// after what it was about rather than leaving it blank until the next
		// thing is asked.
		first.title = sessionTitle(opts.SessionDir, opts.SessionID)
	}
	t.list = []*tab{{m: first}}
	return t
}

// attach hands the program to the tabs once it exists, which is the earliest
// anything can send a message.
func (t *tabs) attach(p sender) {
	t.p = p
	for _, tb := range t.list {
		tb.m.wire(p)
	}
}

// cur is the conversation on screen. There is always one: closing the last
// tab leaves Codey rather than leaving an empty strip.
func (t *tabs) cur() *tab { return t.list[t.active] }

// err is what Run returns: the first failure any tab recorded.
func (t *tabs) err() error {
	for _, tb := range t.list {
		if tb.m.err != nil {
			return tb.m.err
		}
	}
	return nil
}

func (t *tabs) Init() tea.Cmd { return t.cur().m.Init() }

// barRows is how many rows the strip takes. One tab is not a strip: a single
// session looks exactly as it did before, down to the row count.
func (t *tabs) barRows() int {
	if len(t.list) < 2 {
		return 0
	}
	return 1
}

func (t *tabs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		t.width, t.height = msg.Width, msg.Height
		return t, t.resizeAll()

	case forTab:
		return t, t.deliver(msg.id, msg.msg)

	case newTabMsg:
		return t, t.open()

	case closeTabMsg:
		return t, t.close(t.active)

	case tea.KeyMsg:
		if cmd, took := t.onKey(msg); took {
			return t, cmd
		}

	case tea.MouseMsg:
		if rows := t.barRows(); rows > 0 {
			switch {
			case msg.Y >= rows:
				// The conversation is drawn below the strip but laid out as
				// though it owned the screen, so the pointer has to be moved
				// into its frame before it is asked what is under it.
				msg.Y -= rows
			case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft:
				return t, t.onBarMouse(msg)
			default:
				// A drag that has wandered up into the strip is still the
				// transcript's drag, and the wheel over the strip is still the
				// wheel: both go on to the conversation, at its top row.
				msg.Y = 0
			}
		}
		return t, t.toActive(msg)
	}

	return t, t.toActive(msg)
}

// toActive hands a message to the conversation on screen. Anything that is not
// addressed to a particular tab — a key, the mouse, a spinner tick, the
// cursor's blink — is about the one being looked at.
func (t *tabs) toActive(msg tea.Msg) tea.Cmd {
	_, cmd := t.cur().m.Update(msg)
	return cmd
}

// deliver routes a tagged message to the tab that asked for it, wherever that
// tab is. A message for a tab that has since been closed is dropped: there is
// no conversation left for it to belong to.
func (t *tabs) deliver(id int, msg tea.Msg) tea.Cmd {
	for i, tb := range t.list {
		if tb.m.id != id {
			continue
		}
		_, cmd := tb.m.Update(msg)
		if i != t.active {
			if speaks(msg) {
				tb.unseen = true
			}
			// An approval is the one thing that cannot wait to be noticed: the
			// agent asking for it is stopped dead until it is answered, and a
			// mark on a tab does not say that. So the tab you are in says it.
			if _, ok := msg.(approvalMsg); ok {
				t.cur().m.tr.addNotice(fmt.Sprintf("tab %d is waiting for you to approve something — alt+%d to answer it", i+1, i+1))
				cmd = tea.Batch(cmd, t.cur().m.sync())
			}
		}
		return cmd
	}
	return nil
}

// speaks says whether a message is the sort of thing worth marking a tab for.
// Usage numbers and token counts arrive constantly and say nothing; text, a
// finished turn and a request to approve something are what you would want to
// be told about from another tab.
func speaks(msg tea.Msg) bool {
	switch msg.(type) {
	case evText, evNotice, turnDone, localDone, worktreeReady, compactDone, approvalMsg, herdrSkillMsg:
		return true
	}
	return false
}

// onKey answers the keys the strip owns and says whether it took the key. It
// takes nothing while the wizard is up: that screen owns the terminal, and a
// tab opened behind it would be a conversation you could not see.
func (t *tabs) onKey(msg tea.KeyMsg) (tea.Cmd, bool) {
	if t.cur().m.wiz != nil {
		return nil, false
	}
	switch key := msg.String(); key {
	case "ctrl+t":
		return t.open(), true
	case "ctrl+pgdown":
		return t.step(1), true
	case "ctrl+pgup":
		return t.step(-1), true
	case "alt+w":
		return t.close(t.active), true
	default:
		// alt+<n> goes straight to a tab. ctrl+<n> is not offered: terminals
		// do not agree on what most of those even are.
		if n, ok := altDigit(key); ok && n <= len(t.list) {
			return t.activate(n - 1), true
		}
	}
	return nil, false
}

// altDigit reads "alt+3" as 3.
func altDigit(key string) (int, bool) {
	if len(key) != len("alt+1") || !strings.HasPrefix(key, "alt+") {
		return 0, false
	}
	c := key[len(key)-1]
	if c < '1' || c > '9' {
		return 0, false
	}
	return int(c - '0'), true
}

// open starts another conversation and switches to it.
func (t *tabs) open() tea.Cmd {
	cur := t.cur().m
	if t.base.NewSession == nil {
		cur.tr.addError("this run cannot open a second session — tabs need a front end that can build one")
		return cur.sync()
	}
	if len(t.list) >= maxTabs {
		cur.tr.addError(fmt.Sprintf("%d tabs is the limit — close one with /close (alt+w) first", maxTabs))
		return cur.sync()
	}

	// A new tab starts where the one you were in is working, not where Codey
	// was started: a second conversation about a worktree belongs in that
	// worktree, and having it open in the main checkout instead would be a
	// conversation about files that are not where it thinks they are.
	spec, err := t.base.NewSession(cur.opts.Root)
	if err != nil {
		cur.tr.addError("could not open another session: " + err.Error())
		return cur.sync()
	}

	o := t.base
	o.Agent, o.Policy = spec.Agent, spec.Policy
	o.SessionID, o.ToolNames = spec.SessionID, spec.ToolNames
	o.NewTools, o.Agents = spec.NewTools, spec.Agents
	o.Root, o.Worktree = cur.opts.Root, cur.opts.Worktree
	// The config as it stands, not as it was loaded: a theme or a setting
	// changed this run is in force on the screen, and a new tab that reverted
	// it would repaint everything back to what was saved on disk.
	o.Config = cur.opts.Config
	// The banner and the wizard belong to how this run started, and it has
	// started: neither is repeated in a tab opened an hour later.
	o.Banner, o.FirstRun = nil, false
	// The settings in force, not the ones saved: a model, an effort or an
	// expanded tool output chosen in this run carries into the new tab,
	// because that is plainly what "another one of these" means.
	o.Effort, o.Verbose = cur.opts.Effort, cur.tr.verbose

	m := newModel(t.ctx, o)
	t.last++
	m.id, m.bridge.tab = t.last, t.last
	m.wire(t.p)
	m.mouseOn = cur.mouseOn

	t.list = append(t.list, &tab{m: m, close: spec.Close})
	return tea.Batch(t.resizeAll(), t.activate(len(t.list)-1))
}

// close ends one conversation. The session is written out first — a turn saves
// on its way out, but a compaction or a subagent's report since then has not
// been — and whatever the session holds open is released.
func (t *tabs) close(i int) tea.Cmd {
	if i < 0 || i >= len(t.list) {
		return nil
	}
	tb := t.list[i]
	if tb.m.busy() {
		tb.m.tr.addError("something is still running here — interrupt it with esc before closing the tab")
		return tb.m.sync()
	}
	if len(t.list) == 1 {
		// The last tab is the app. Closing it means the same thing as /exit,
		// and saying so is better than refusing.
		return tb.m.quit()
	}

	var saveErr error
	if tb.m.opts.SessionID != "" && tb.m.turns > 0 {
		saveErr = tb.m.saveSession()
	}
	if tb.close != nil {
		tb.close()
	}

	t.list = append(t.list[:i], t.list[i+1:]...)
	// Which tab you end up in: the one you were in if it is still here, and
	// otherwise the one to its left, the way closing a tab anywhere else does.
	next := t.active
	if i < next || next >= len(t.list) {
		next--
	}
	t.active = maxInt(0, minInt(next, len(t.list)-1))

	cmd := tea.Batch(t.resizeAll(), t.activate(t.active))
	if saveErr != nil {
		// There is nowhere left to say it but the tab that is staying.
		t.cur().m.tr.addError("could not save session " + tb.m.opts.SessionID + ": " + saveErr.Error())
		cmd = tea.Batch(cmd, t.cur().m.sync())
	}
	return cmd
}

// step moves along the strip, wrapping round at either end.
func (t *tabs) step(by int) tea.Cmd {
	n := len(t.list)
	if n < 2 {
		return nil
	}
	return t.activate(((t.active+by)%n + n) % n)
}

// activate puts a conversation on screen. A tab that was working while it was
// away gets its spinner started again: the ticks that drove it went to
// whatever was on screen at the time and were ignored there.
func (t *tabs) activate(i int) tea.Cmd {
	if i < 0 || i >= len(t.list) {
		return nil
	}
	// Mouse reporting is the terminal's state, not a tab's: whoever last used
	// /mouse decided it for the whole screen, and a tab that disagreed would
	// wait for events the terminal is no longer sending.
	mouseOn := t.cur().m.mouseOn
	t.active = i
	tb := t.list[i]
	tb.m.mouseOn = mouseOn
	tb.unseen = false

	cmds := []tea.Cmd{tb.m.sync(), textarea.Blink}
	if tb.m.busy() {
		cmds = append(cmds, tb.m.spin.Tick)
	}
	return tea.Batch(cmds...)
}

// resizeAll lays every tab out for the room left under the strip. Tabs that
// are not on screen are sized too, so that switching to one shows a frame
// already fitted to the terminal rather than one drawn for its last size.
func (t *tabs) resizeAll() tea.Cmd {
	if t.width == 0 && t.height == 0 {
		return nil
	}
	h := maxInt(1, t.height-t.barRows())
	var cmds []tea.Cmd
	for _, tb := range t.list {
		cmds = append(cmds, tb.m.resize(t.width, h))
	}
	return tea.Batch(cmds...)
}

func (t *tabs) View() string {
	cur := t.cur().m
	if cur.quitting {
		return ""
	}
	if t.barRows() == 0 {
		return cur.View()
	}
	return t.barView() + "\n" + cur.View()
}

// barView draws the strip: the conversations, and — if the row has the room
// for it — what the keys are.
func (t *tabs) barView() string {
	labels := make([]string, len(t.list))
	for i, tb := range t.list {
		labels[i] = t.label(i, tb)
	}
	left := strings.Join(labels, "")

	hint := styFaint.Render("ctrl+t new · ctrl+pgup/pgdn · alt+1…9 · alt+w close")
	if gap := t.width - lipgloss.Width(left) - lipgloss.Width(hint) - 1; gap > 0 {
		return truncateStyled(left+strings.Repeat(" ", gap)+hint, t.width)
	}
	return truncateStyled(left, t.width)
}

// label is one tab in the strip: its number, a mark for what has happened in
// it, and what it was first asked about.
func (t *tabs) label(i int, tb *tab) string {
	name := tb.m.title
	if name == "" {
		name = "new session"
	}
	name = truncate(name, tabNameWidth)

	mark := ""
	switch {
	case tb.m.busy():
		// The same dot the activity line uses, so "working" looks like
		// "working" wherever it is said.
		mark = "◍ "
	case tb.unseen:
		mark = "● "
	}

	text := fmt.Sprintf("%d %s%s", i+1, mark, name)
	if i == t.active {
		return styTabOn.Render(text)
	}
	if tb.unseen {
		return styTabMark.Render(text)
	}
	return styTabOff.Render(text)
}

// tabNameWidth is how much of a conversation's opening prompt the strip shows.
// Enough to tell two tabs apart, short enough that nine of them fit.
const tabNameWidth = 18

// onBarMouse switches tabs by clicking one. The strip is one row, so the only
// question is which label the pointer is over.
func (t *tabs) onBarMouse(msg tea.MouseMsg) tea.Cmd {
	if !t.cur().m.mouseOn || msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
		return nil
	}
	x := 0
	for i, tb := range t.list {
		w := lipgloss.Width(t.label(i, tb))
		if msg.X >= x && msg.X < x+w {
			return t.activate(i)
		}
		x += w
	}
	return nil
}

// firstLine is a prompt reduced to something a tab can be called: its opening
// line, with the whitespace taken out of it.
func firstLine(s string) string {
	s = strings.TrimSpace(s)
	if i := strings.IndexByte(s, '\n'); i >= 0 {
		s = s[:i]
	}
	return strings.Join(strings.Fields(s), " ")
}