From 2fa00ad510078b403452b14737f42c2ab9befd62 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Mon, 18 May 2026 13:02:35 +0100 Subject: [PATCH] Show idle state in the top tab bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 +++ TODO.md | 1 - internal/app/app.go | 8 ++-- internal/app/tabbar.go | 83 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9de62c..363f336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [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 - Built-in agent presets (`claude`, `codex`, `opencode`) now live in memory and user preset files merge over them by name instead of diff --git a/TODO.md b/TODO.md index 0d8a6ad..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1 +0,0 @@ -- [ ] We should show idle state in the top tab bar as well diff --git a/internal/app/app.go b/internal/app/app.go index 990ef5c..c3c23cc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() } diff --git a/internal/app/tabbar.go b/internal/app/tabbar.go index e884ffb..55341f4 100644 --- a/internal/app/tabbar.go +++ b/internal/app/tabbar.go @@ -59,10 +59,12 @@ func (st *uiState) drawTabBar() { newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing type tabRect struct { - startCol int - width int - label string - active bool + 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 } @@ -130,10 +139,12 @@ func (st *uiState) drawTabBar() { labelW = utf8.RuneCountInString(label) } tabs = append(tabs, tabRect{ - startCol: col, - width: w, - label: label, - active: c.ID == focus, + startCol: col, + width: w, + label: label, + 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 "", "" + } +}