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+ 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+ goes straight to a tab. ctrl+ 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), " ") }