detached a0fb45a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
111111
112112 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.
113113
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.
115115
116116 ## Remote attach
117117
@@ -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 attach116 ## 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) {
7575 continue
7676 }
7777 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)})
7979 interval = minimumBarInterval
8080 }
8181 cfg.Bar.Interval = interval
@@ -75,7 +75,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
75 continue75 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 = minimumBarInterval79 interval = minimumBarInterval
80 }80 }
81 cfg.Bar.Interval = interval81 cfg.Bar.Interval = interval
M internal/usecase/daemon/bar_refresh.go +9 -2
@@ -33,6 +33,7 @@ type barScriptState struct {
3333 lastRefresh map[domain.SessionID]time.Time
3434 lastContext map[domain.SessionID]barScriptContext
3535 running map[domain.SessionID]bool
36+ version uint64
3637 }
3738
3839 func effectiveBarInterval(d time.Duration) time.Duration {
@@ -180,12 +181,13 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
180181 d.barScripts.lastContext[sess.id] = baseCtx
181182 cfg := d.barScripts.cfg
182183 runner := d.barScripts.runner
184+ version := d.barScripts.version
183185 d.barScripts.mu.Unlock()
184- go d.runBarScripts(sess, runner, cfg, baseCtx)
186+ go d.runBarScripts(sess, runner, cfg, baseCtx, version)
185187 return true
186188 }
187189
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) {
189191 if runner == nil {
190192 runner = barScriptRunner{baseEnv: d.baseEnv}
191193 }
@@ -197,6 +199,11 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
197199 bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right")
198200 d.barScripts.mu.Lock()
199201 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+ }
200207 current := d.barScripts.outputs[sess.id]
201208 changed := false
202209 if topOK && current.topRight != top {
@@ -33,6 +33,7 @@ type barScriptState struct {
33 lastRefresh map[domain.SessionID]time.Time33 lastRefresh map[domain.SessionID]time.Time
34 lastContext map[domain.SessionID]barScriptContext34 lastContext map[domain.SessionID]barScriptContext
35 running map[domain.SessionID]bool35 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] = baseCtx181 d.barScripts.lastContext[sess.id] = baseCtx
181 cfg := d.barScripts.cfg182 cfg := d.barScripts.cfg
182 runner := d.barScripts.runner183 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 true187 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 := false208 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) {
113113 require.Equal(t, "/other", r.calls[2].PaneCWD)
114114 }
115115
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+
116148 func TestBarScriptRefreshIsPerSession(t *testing.T) {
117149 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
118150 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) {
3434 d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
3535 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
3636 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
37- d.barScripts.running = make(map[domain.SessionID]bool)
37+ d.barScripts.version++
3838 d.barScripts.mu.Unlock()
3939 }
4040 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)
77
88 cpu_pct=
99 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
2126 fi
2227
2328 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 ]; then9 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 + $613+ if [ -n "$first" ] && [ -n "$second" ]; then
14- total = 014+ cpu_pct=$(
15- for (i = 2; i <= NF; i++) total += $i15+ awk -v first="$first" -v second="$second" '
16- if (total > 0) printf "%d", ((total - idle) * 100) / total16+ BEGIN {
17- exit17+ split(first, a, " ")
18- }18+ split(second, b, " ")
19- ' /proc/stat 2>/dev/null || true19+ 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 fi26 fi
22 27
23 mem_pct=28 mem_pct=

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close