Merge pull request 'Show idle state in the top tab bar + release v0.0.7' (#7) from worktree-timers-cancel-on-close into main

This commit was merged in pull request #7.
This commit is contained in:
2026-05-18 13:25:38 +01:00
4 changed files with 79 additions and 21 deletions

View File

@@ -6,6 +6,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.0.7] - 2026-05-18
### 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
- Built-in agent presets (`claude`, `codex`, `opencode`) now live in
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()
}
// OnChildStateChanged repaints the sidebar whenever a child's
// idle-state badge flips. Cheap — the badge is the only chrome that
// reflects state today, and drawSidebar bails when the cached frame
// hasn't changed.
// OnChildStateChanged repaints the sidebar and tab bar whenever a
// child's idle-state badge flips. Cheap — both draws bail when the
// cached frame hasn't changed.
func (st *uiState) OnChildStateChanged(string, IdleState) {
st.drawTabBar()
st.drawSidebar()
}

View File

@@ -62,6 +62,8 @@ func (st *uiState) drawTabBar() {
startCol int
width int
label string
glyph string
glyphStyle string
active bool
}
activeTab := -1
@@ -115,9 +117,16 @@ func (st *uiState) drawTabBar() {
if i < extra {
w++
}
active := c.ID == focus
glyph, glyphStyle := tabIdleGlyph(c.IdleState(), active)
label := c.DisplayName()
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 {
maxLabelW = 1
}
@@ -133,7 +142,9 @@ func (st *uiState) drawTabBar() {
startCol: col,
width: w,
label: label,
active: c.ID == focus,
glyph: glyph,
glyphStyle: glyphStyle,
active: active,
})
if tabs[len(tabs)-1].active {
activeTab = len(tabs) - 1
@@ -155,23 +166,37 @@ func (st *uiState) drawTabBar() {
fmt.Fprintf(&b, "\x1b[3;1H\x1b[%dX", width)
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)
leftPad := (t.width - labelW) / 2
visibleW := labelW
if t.glyph != "" {
visibleW += 2 // glyph + separator space
}
leftPad := (t.width - visibleW) / 2
if leftPad < 1 {
leftPad = 1
}
rightPad := t.width - labelW - leftPad
rightPad := t.width - visibleW - leftPad
if rightPad < 0 {
rightPad = 0
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
cellStyle := styleHint
if t.active {
b.WriteString(styleActive)
} else {
b.WriteString(styleHint)
cellStyle = styleActive
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
b.WriteString(cellStyle)
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(strings.Repeat(" ", rightPad))
b.WriteString(styleReset)
@@ -226,3 +251,29 @@ func (st *uiState) drawTabBar() {
defer st.outMu.Unlock()
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 "", ""
}
}