Show idle state in the top tab bar

Each agent tab now prefixes its label with the same one-rune idle
indicator the sidebar uses (✕ error, ? permission, ◐ thinking, ○ idle,
● working), so the state of every open agent is visible without
opening or focusing each tab. Tab redraws now fire on idle-state
changes in addition to sidebar redraws.
This commit is contained in:
2026-05-18 13:02:35 +01:00
parent 34b41be1df
commit 2fa00ad510
4 changed files with 77 additions and 21 deletions

View File

@@ -6,6 +6,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- The top tab bar now prefixes each agent tab's label with its
idle-state glyph (✕ error, ? permission, ◐ thinking, ○ idle, ●
working), matching the sidebar's vocabulary so the state of every
open agent is visible without opening or focusing each tab.
### Changed ### Changed
- Built-in agent presets (`claude`, `codex`, `opencode`) now live in - Built-in agent presets (`claude`, `codex`, `opencode`) now live in
memory and user preset files merge over them by name instead of memory and user preset files merge over them by name instead of

View File

@@ -1 +0,0 @@
- [ ] We should show idle state in the top tab bar as well

View File

@@ -829,11 +829,11 @@ func (st *uiState) OnChildSpawned(c *Child) {
st.drawStatusLine() st.drawStatusLine()
} }
// OnChildStateChanged repaints the sidebar whenever a child's // OnChildStateChanged repaints the sidebar and tab bar whenever a
// idle-state badge flips. Cheap — the badge is the only chrome that // child's idle-state badge flips. Cheap — both draws bail when the
// reflects state today, and drawSidebar bails when the cached frame // cached frame hasn't changed.
// hasn't changed.
func (st *uiState) OnChildStateChanged(string, IdleState) { func (st *uiState) OnChildStateChanged(string, IdleState) {
st.drawTabBar()
st.drawSidebar() st.drawSidebar()
} }

View File

@@ -59,10 +59,12 @@ func (st *uiState) drawTabBar() {
newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing
type tabRect struct { type tabRect struct {
startCol int startCol int
width int width int
label string label string
active bool glyph string
glyphStyle string
active bool
} }
activeTab := -1 activeTab := -1
@@ -115,9 +117,16 @@ func (st *uiState) drawTabBar() {
if i < extra { if i < extra {
w++ w++
} }
active := c.ID == focus
glyph, glyphStyle := tabIdleGlyph(c.IdleState(), active)
label := c.DisplayName() label := c.DisplayName()
labelW := utf8.RuneCountInString(label) labelW := utf8.RuneCountInString(label)
maxLabelW := w - 2 // one pad on each side // Reserve room for the glyph + its trailing space when present
// (1 + 1 runes), on top of the one-cell pad on each side.
maxLabelW := w - 2
if glyph != "" {
maxLabelW -= 2
}
if maxLabelW < 1 { if maxLabelW < 1 {
maxLabelW = 1 maxLabelW = 1
} }
@@ -130,10 +139,12 @@ func (st *uiState) drawTabBar() {
labelW = utf8.RuneCountInString(label) labelW = utf8.RuneCountInString(label)
} }
tabs = append(tabs, tabRect{ tabs = append(tabs, tabRect{
startCol: col, startCol: col,
width: w, width: w,
label: label, label: label,
active: c.ID == focus, glyph: glyph,
glyphStyle: glyphStyle,
active: active,
}) })
if tabs[len(tabs)-1].active { if tabs[len(tabs)-1].active {
activeTab = len(tabs) - 1 activeTab = len(tabs) - 1
@@ -155,23 +166,37 @@ func (st *uiState) drawTabBar() {
fmt.Fprintf(&b, "\x1b[3;1H\x1b[%dX", width) fmt.Fprintf(&b, "\x1b[3;1H\x1b[%dX", width)
for _, t := range tabs { for _, t := range tabs {
// Row 1: centre-ish label inside the tab cell. // Row 1: centre-ish glyph+label inside the tab cell.
labelW := utf8.RuneCountInString(t.label) labelW := utf8.RuneCountInString(t.label)
leftPad := (t.width - labelW) / 2 visibleW := labelW
if t.glyph != "" {
visibleW += 2 // glyph + separator space
}
leftPad := (t.width - visibleW) / 2
if leftPad < 1 { if leftPad < 1 {
leftPad = 1 leftPad = 1
} }
rightPad := t.width - labelW - leftPad rightPad := t.width - visibleW - leftPad
if rightPad < 0 { if rightPad < 0 {
rightPad = 0 rightPad = 0
} }
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol) cellStyle := styleHint
if t.active { if t.active {
b.WriteString(styleActive) cellStyle = styleActive
} else {
b.WriteString(styleHint)
} }
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
b.WriteString(cellStyle)
b.WriteString(strings.Repeat(" ", leftPad)) b.WriteString(strings.Repeat(" ", leftPad))
if t.glyph != "" {
// Glyph uses its own colour so error/permission states pop
// regardless of tab focus, matching the sidebar's vocabulary.
b.WriteString(styleReset)
b.WriteString(t.glyphStyle)
b.WriteString(t.glyph)
b.WriteString(styleReset)
b.WriteString(cellStyle)
b.WriteString(" ")
}
b.WriteString(t.label) b.WriteString(t.label)
b.WriteString(strings.Repeat(" ", rightPad)) b.WriteString(strings.Repeat(" ", rightPad))
b.WriteString(styleReset) b.WriteString(styleReset)
@@ -226,3 +251,29 @@ func (st *uiState) drawTabBar() {
defer st.outMu.Unlock() defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame) fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
} }
// tabIdleGlyph returns the one-rune state indicator (and its SGR style)
// to render before a tab's label. Mirrors the sidebar's vocabulary so
// users learn the symbols in one place: ✕ error, ? permission, ◐
// thinking, ○ idle, ● working. Returns ("", "") for StateUnknown so the
// first frame after spawn doesn't show a misleading badge.
func tabIdleGlyph(state IdleState, active bool) (string, string) {
base := styleHint
if active {
base = styleAccent
}
switch state {
case StateError:
return "✕", styleError
case StatePermission:
return "?", styleAccent
case StateThinking:
return "◐", base
case StateIdle:
return "○", base
case StateWorking:
return "●", base
default:
return "", ""
}
}