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:
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), " ")
}