fix(daemon): address bar anchor review findings
Signed-off-by: brice <b@bnema.dev>
- commit
- a0fb45a23f33e681cb969c5503b42dd9a5a0e350
- author
- brice · · 2026-07-06 14:53 UTC
- parent
- 423c15f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +60 -16
M README.md +1 -1
| @@ -111,7 +111,7 @@ The top and bottom bar right anchors run configurable commands on the daemon hos | |||
| 111 | 111 | ||
| 112 | 112 | Both anchors share `bar.interval`, which defaults to `5s`; values below `1s` are clamped to `1s`. vev also refreshes around attach, resize, focus, and copy-feedback changes. Commands are bounded by a timeout and output limit. On failure or timeout, vev keeps the last good value; if there is none, the anchor is empty. | |
| 113 | 113 | ||
| 114 | - | Scripts receive these environment variables when available: `VEV_ANCHOR` (`top-right` or `bottom-right`), `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. vev reads stdout only, uses the first line only, treats it as plain UTF-8 display text, and strips ANSI escape sequences and other control characters. Unicode and Nerd Font glyphs are supported. The v1 API does not support colors, styles, JSON, streaming output, or per-anchor intervals. | |
| 114 | + | Scripts receive these environment variables when available: `VEV_ANCHOR` (`top-right` or `bottom-right`), `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. vev reads stdout only, uses the first line only, treats it as plain UTF-8 display text, and strips ANSI escape sequences and other control characters. Unicode and Nerd Font glyphs are supported. The v1 API does not support colors, styles, JSON, streaming output, or per-anchor intervals. The default git script runs `git status --porcelain` each refresh, so increase `bar.interval` or replace the command if that is too expensive for a large repository. | |
| 115 | 115 | ||
| 116 | 116 | ## Remote attach | |
| 117 | 117 | ||
| @@ -111,7 +111,7 @@ The top and bottom bar right anchors run configurable commands on the daemon hos | |||||
| 111 | 111 | ||||
| 112 | Both anchors share `bar.interval`, which defaults to `5s`; values below `1s` are clamped to `1s`. vev also refreshes around attach, resize, focus, and copy-feedback changes. Commands are bounded by a timeout and output limit. On failure or timeout, vev keeps the last good value; if there is none, the anchor is empty. | 112 | Both anchors share `bar.interval`, which defaults to `5s`; values below `1s` are clamped to `1s`. vev also refreshes around attach, resize, focus, and copy-feedback changes. Commands are bounded by a timeout and output limit. On failure or timeout, vev keeps the last good value; if there is none, the anchor is empty. | ||
| 113 | 113 | ||||
| 114 | - | Scripts receive these environment variables when available: `VEV_ANCHOR` (`top-right` or `bottom-right`), `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. vev reads stdout only, uses the first line only, treats it as plain UTF-8 display text, and strips ANSI escape sequences and other control characters. Unicode and Nerd Font glyphs are supported. The v1 API does not support colors, styles, JSON, streaming output, or per-anchor intervals. | 114 | + | Scripts receive these environment variables when available: `VEV_ANCHOR` (`top-right` or `bottom-right`), `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. vev reads stdout only, uses the first line only, treats it as plain UTF-8 display text, and strips ANSI escape sequences and other control characters. Unicode and Nerd Font glyphs are supported. The v1 API does not support colors, styles, JSON, streaming output, or per-anchor intervals. The default git script runs `git status --porcelain` each refresh, so increase `bar.interval` or replace the command if that is too expensive for a large repository. |
| 115 | 115 | ||||
| 116 | ## Remote attach | 116 | ## Remote attach | ||
| 117 | 117 | ||||
M internal/adapters/config/config.go +1 -1
| @@ -75,7 +75,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 75 | 75 | continue | |
| 76 | 76 | } | |
| 77 | 77 | if interval < minimumBarInterval { | |
| 78 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "bar.interval below minimum \"1s\""}) | |
| 78 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", minimumBarInterval)}) | |
| 79 | 79 | interval = minimumBarInterval | |
| 80 | 80 | } | |
| 81 | 81 | cfg.Bar.Interval = interval | |
| @@ -75,7 +75,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 75 | continue | 75 | continue | ||
| 76 | } | 76 | } | ||
| 77 | if interval < minimumBarInterval { | 77 | if interval < minimumBarInterval { | ||
| 78 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "bar.interval below minimum \"1s\""}) | 78 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", minimumBarInterval)}) |
| 79 | interval = minimumBarInterval | 79 | interval = minimumBarInterval | ||
| 80 | } | 80 | } | ||
| 81 | cfg.Bar.Interval = interval | 81 | cfg.Bar.Interval = interval | ||
M internal/usecase/daemon/bar_refresh.go +9 -2
| @@ -33,6 +33,7 @@ type barScriptState struct { | |||
| 33 | 33 | lastRefresh map[domain.SessionID]time.Time | |
| 34 | 34 | lastContext map[domain.SessionID]barScriptContext | |
| 35 | 35 | running map[domain.SessionID]bool | |
| 36 | + | version uint64 | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | 39 | func effectiveBarInterval(d time.Duration) time.Duration { | |
| @@ -180,12 +181,13 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||
| 180 | 181 | d.barScripts.lastContext[sess.id] = baseCtx | |
| 181 | 182 | cfg := d.barScripts.cfg | |
| 182 | 183 | runner := d.barScripts.runner | |
| 184 | + | version := d.barScripts.version | |
| 183 | 185 | d.barScripts.mu.Unlock() | |
| 184 | - | go d.runBarScripts(sess, runner, cfg, baseCtx) | |
| 186 | + | go d.runBarScripts(sess, runner, cfg, baseCtx, version) | |
| 185 | 187 | return true | |
| 186 | 188 | } | |
| 187 | 189 | ||
| 188 | - | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext) { | |
| 190 | + | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { | |
| 189 | 191 | if runner == nil { | |
| 190 | 192 | runner = barScriptRunner{baseEnv: d.baseEnv} | |
| 191 | 193 | } | |
| @@ -197,6 +199,11 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||
| 197 | 199 | bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right") | |
| 198 | 200 | d.barScripts.mu.Lock() | |
| 199 | 201 | d.barScripts.initLocked() | |
| 202 | + | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | |
| 203 | + | delete(d.barScripts.running, sess.id) | |
| 204 | + | d.barScripts.mu.Unlock() | |
| 205 | + | return | |
| 206 | + | } | |
| 200 | 207 | current := d.barScripts.outputs[sess.id] | |
| 201 | 208 | changed := false | |
| 202 | 209 | if topOK && current.topRight != top { | |
| @@ -33,6 +33,7 @@ type barScriptState struct { | |||||
| 33 | lastRefresh map[domain.SessionID]time.Time | 33 | lastRefresh map[domain.SessionID]time.Time | ||
| 34 | lastContext map[domain.SessionID]barScriptContext | 34 | lastContext map[domain.SessionID]barScriptContext | ||
| 35 | running map[domain.SessionID]bool | 35 | running map[domain.SessionID]bool | ||
| 36 | + | version uint64 | |||
| 36 | } | 37 | } | ||
| 37 | 38 | ||||
| 38 | func effectiveBarInterval(d time.Duration) time.Duration { | 39 | func effectiveBarInterval(d time.Duration) time.Duration { | ||
| @@ -180,12 +181,13 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||||
| 180 | d.barScripts.lastContext[sess.id] = baseCtx | 181 | d.barScripts.lastContext[sess.id] = baseCtx | ||
| 181 | cfg := d.barScripts.cfg | 182 | cfg := d.barScripts.cfg | ||
| 182 | runner := d.barScripts.runner | 183 | runner := d.barScripts.runner | ||
| 184 | + | version := d.barScripts.version | |||
| 183 | d.barScripts.mu.Unlock() | 185 | d.barScripts.mu.Unlock() | ||
| 184 | - | go d.runBarScripts(sess, runner, cfg, baseCtx) | 186 | + | go d.runBarScripts(sess, runner, cfg, baseCtx, version) |
| 185 | return true | 187 | return true | ||
| 186 | } | 188 | } | ||
| 187 | 189 | ||||
| 188 | - | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext) { | 190 | + | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { |
| 189 | if runner == nil { | 191 | if runner == nil { | ||
| 190 | runner = barScriptRunner{baseEnv: d.baseEnv} | 192 | runner = barScriptRunner{baseEnv: d.baseEnv} | ||
| 191 | } | 193 | } | ||
| @@ -197,6 +199,11 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||||
| 197 | bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right") | 199 | bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right") | ||
| 198 | d.barScripts.mu.Lock() | 200 | d.barScripts.mu.Lock() | ||
| 199 | d.barScripts.initLocked() | 201 | d.barScripts.initLocked() | ||
| 202 | + | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | |||
| 203 | + | delete(d.barScripts.running, sess.id) | |||
| 204 | + | d.barScripts.mu.Unlock() | |||
| 205 | + | return | |||
| 206 | + | } | |||
| 200 | current := d.barScripts.outputs[sess.id] | 207 | current := d.barScripts.outputs[sess.id] | ||
| 201 | changed := false | 208 | changed := false | ||
| 202 | if topOK && current.topRight != top { | 209 | if topOK && current.topRight != top { | ||
M internal/usecase/daemon/bar_refresh_test.go +32 -0
| @@ -113,6 +113,38 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||
| 113 | 113 | require.Equal(t, "/other", r.calls[2].PaneCWD) | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | + | func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |
| 117 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 118 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 119 | + | sess := newBarRefreshTestSession() | |
| 120 | + | d.barScripts.mu.Lock() | |
| 121 | + | d.barScripts.running[sess.id] = true | |
| 122 | + | d.barScripts.mu.Unlock() | |
| 123 | + | ||
| 124 | + | d.clearBarScriptsForSession(sess.id) | |
| 125 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0) | |
| 126 | + | ||
| 127 | + | state := d.barStateFor(sess, "") | |
| 128 | + | require.Empty(t, state.topRight) | |
| 129 | + | require.Empty(t, state.bottomRight) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |
| 133 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 134 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 135 | + | sess := newBarRefreshTestSession() | |
| 136 | + | d.barScripts.mu.Lock() | |
| 137 | + | d.barScripts.running[sess.id] = true | |
| 138 | + | d.barScripts.version = 2 | |
| 139 | + | d.barScripts.mu.Unlock() | |
| 140 | + | ||
| 141 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1) | |
| 142 | + | ||
| 143 | + | state := d.barStateFor(sess, "") | |
| 144 | + | require.Empty(t, state.topRight) | |
| 145 | + | require.Empty(t, state.bottomRight) | |
| 146 | + | } | |
| 147 | + | ||
| 116 | 148 | func TestBarScriptRefreshIsPerSession(t *testing.T) { | |
| 117 | 149 | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | |
| 118 | 150 | d := newBarRefreshTestDaemon(r, time.Second) | |
| @@ -113,6 +113,38 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||||
| 113 | require.Equal(t, "/other", r.calls[2].PaneCWD) | 113 | require.Equal(t, "/other", r.calls[2].PaneCWD) | ||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | + | func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |||
| 117 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |||
| 118 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 119 | + | sess := newBarRefreshTestSession() | |||
| 120 | + | d.barScripts.mu.Lock() | |||
| 121 | + | d.barScripts.running[sess.id] = true | |||
| 122 | + | d.barScripts.mu.Unlock() | |||
| 123 | + | ||||
| 124 | + | d.clearBarScriptsForSession(sess.id) | |||
| 125 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0) | |||
| 126 | + | ||||
| 127 | + | state := d.barStateFor(sess, "") | |||
| 128 | + | require.Empty(t, state.topRight) | |||
| 129 | + | require.Empty(t, state.bottomRight) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |||
| 133 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |||
| 134 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 135 | + | sess := newBarRefreshTestSession() | |||
| 136 | + | d.barScripts.mu.Lock() | |||
| 137 | + | d.barScripts.running[sess.id] = true | |||
| 138 | + | d.barScripts.version = 2 | |||
| 139 | + | d.barScripts.mu.Unlock() | |||
| 140 | + | ||||
| 141 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1) | |||
| 142 | + | ||||
| 143 | + | state := d.barStateFor(sess, "") | |||
| 144 | + | require.Empty(t, state.topRight) | |||
| 145 | + | require.Empty(t, state.bottomRight) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 116 | func TestBarScriptRefreshIsPerSession(t *testing.T) { | 148 | func TestBarScriptRefreshIsPerSession(t *testing.T) { | ||
| 117 | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | 149 | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | ||
| 118 | d := newBarRefreshTestDaemon(r, time.Second) | 150 | d := newBarRefreshTestDaemon(r, time.Second) | ||
M internal/usecase/daemon/config.go +1 -1
| @@ -34,7 +34,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 34 | 34 | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | |
| 35 | 35 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 36 | 36 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |
| 37 | - | d.barScripts.running = make(map[domain.SessionID]bool) | |
| 37 | + | d.barScripts.version++ | |
| 38 | 38 | d.barScripts.mu.Unlock() | |
| 39 | 39 | } | |
| 40 | 40 | d.reapplyThemeAllSessions() | |
| @@ -34,7 +34,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 34 | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | 34 | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | ||
| 35 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | 35 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | ||
| 36 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | 36 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | ||
| 37 | - | d.barScripts.running = make(map[domain.SessionID]bool) | 37 | + | d.barScripts.version++ |
| 38 | d.barScripts.mu.Unlock() | 38 | d.barScripts.mu.Unlock() | ||
| 39 | } | 39 | } | ||
| 40 | d.reapplyThemeAllSessions() | 40 | d.reapplyThemeAllSessions() | ||
M scripts/vev-bar-top-right +16 -11
| @@ -7,17 +7,22 @@ now=$(date +%H:%M 2>/dev/null || true) | |||
| 7 | 7 | ||
| 8 | 8 | cpu_pct= | |
| 9 | 9 | if [ -r /proc/stat ]; then | |
| 10 | - | cpu_pct=$( | |
| 11 | - | awk ' | |
| 12 | - | $1 == "cpu" { | |
| 13 | - | idle = $5 + $6 | |
| 14 | - | total = 0 | |
| 15 | - | for (i = 2; i <= NF; i++) total += $i | |
| 16 | - | if (total > 0) printf "%d", ((total - idle) * 100) / total | |
| 17 | - | exit | |
| 18 | - | } | |
| 19 | - | ' /proc/stat 2>/dev/null || true | |
| 20 | - | ) | |
| 10 | + | first=$(awk '$1 == "cpu" { idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; print idle, total; exit }' /proc/stat 2>/dev/null || true) | |
| 11 | + | sleep 0.1 2>/dev/null || true | |
| 12 | + | second=$(awk '$1 == "cpu" { idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; print idle, total; exit }' /proc/stat 2>/dev/null || true) | |
| 13 | + | if [ -n "$first" ] && [ -n "$second" ]; then | |
| 14 | + | cpu_pct=$( | |
| 15 | + | awk -v first="$first" -v second="$second" ' | |
| 16 | + | BEGIN { | |
| 17 | + | split(first, a, " ") | |
| 18 | + | split(second, b, " ") | |
| 19 | + | idle = b[1] - a[1] | |
| 20 | + | total = b[2] - a[2] | |
| 21 | + | if (total > 0) printf "%d", ((total - idle) * 100) / total | |
| 22 | + | } | |
| 23 | + | ' 2>/dev/null || true | |
| 24 | + | ) | |
| 25 | + | fi | |
| 21 | 26 | fi | |
| 22 | 27 | ||
| 23 | 28 | mem_pct= | |
| @@ -7,17 +7,22 @@ now=$(date +%H:%M 2>/dev/null || true) | |||||
| 7 | 7 | ||||
| 8 | cpu_pct= | 8 | cpu_pct= | ||
| 9 | if [ -r /proc/stat ]; then | 9 | if [ -r /proc/stat ]; then | ||
| 10 | - | cpu_pct=$( | 10 | + | first=$(awk '$1 == "cpu" { idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; print idle, total; exit }' /proc/stat 2>/dev/null || true) |
| 11 | - | awk ' | 11 | + | sleep 0.1 2>/dev/null || true |
| 12 | - | $1 == "cpu" { | 12 | + | second=$(awk '$1 == "cpu" { idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; print idle, total; exit }' /proc/stat 2>/dev/null || true) |
| 13 | - | idle = $5 + $6 | 13 | + | if [ -n "$first" ] && [ -n "$second" ]; then |
| 14 | - | total = 0 | 14 | + | cpu_pct=$( |
| 15 | - | for (i = 2; i <= NF; i++) total += $i | 15 | + | awk -v first="$first" -v second="$second" ' |
| 16 | - | if (total > 0) printf "%d", ((total - idle) * 100) / total | 16 | + | BEGIN { |
| 17 | - | exit | 17 | + | split(first, a, " ") |
| 18 | - | } | 18 | + | split(second, b, " ") |
| 19 | - | ' /proc/stat 2>/dev/null || true | 19 | + | idle = b[1] - a[1] |
| 20 | - | ) | 20 | + | total = b[2] - a[2] |
| 21 | + | if (total > 0) printf "%d", ((total - idle) * 100) / total | |||
| 22 | + | } | |||
| 23 | + | ' 2>/dev/null || true | |||
| 24 | + | ) | |||
| 25 | + | fi | |||
| 21 | fi | 26 | fi | ||
| 22 | 27 | ||||
| 23 | mem_pct= | 28 | mem_pct= | ||