detached 5e82673

feat(daemon): add scriptable bar right anchors

Signed-off-by: brice <b@bnema.dev>
commit
5e8267317d9a58c41b796a3b560322e05de653e9
author
brice · · 2026-07-06 14:29 UTC
parent
14f582a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +968 -8
M Makefile +4 -0
@@ -2,6 +2,10 @@
22
33 install:
44 go install .
5+ @bin="$$(go env GOBIN)"; \
6+ if [ -z "$$bin" ]; then bin="$$(go env GOPATH)/bin"; fi; \
7+ mkdir -p "$$bin"; \
8+ install -m 0755 scripts/vev-bar-top-right scripts/vev-bar-bottom-right "$$bin"
59
610 test:
711 go test ./... -race
@@ -2,6 +2,10 @@
2 2
3 install:3 install:
4 go install .4 go install .
5+ @bin="$$(go env GOBIN)"; \
6+ if [ -z "$$bin" ]; then bin="$$(go env GOPATH)/bin"; fi; \
7+ mkdir -p "$$bin"; \
8+ install -m 0755 scripts/vev-bar-top-right scripts/vev-bar-bottom-right "$$bin"
5 9
6 test:10 test:
7 go test ./... -race11 go test ./... -race
M README.md +16 -1
@@ -15,10 +15,12 @@ go install github.com/bnema/vev@latest
1515 From a checkout:
1616
1717 ```sh
18-make install # go install .
18+make install # install vev and default bar scripts to Go's bin directory
1919 go build -o vev . # local binary
2020 ```
2121
22+`make install` places `vev-bar-top-right` and `vev-bar-bottom-right` beside the installed `vev` binary. If you install another way, copy the scripts from `scripts/` into a directory on `PATH` or replace the bar commands in config.
23+
2224 ## Usage
2325
2426 ```text
@@ -69,6 +71,11 @@ switch-tab-9 = alt+9
6971 # set it empty to disable process relaunch.
7072 snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
7173
74+# Right bar anchors. Empty command values disable the matching anchor.
75+bar.top-right = vev-bar-top-right
76+bar.bottom-right = vev-bar-bottom-right
77+bar.interval = 5s
78+
7279 # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.
7380 code.new-tab = CNT
7481 code.new-session = CNS
@@ -98,6 +105,14 @@ Configuring an action replaces all of its built-in aliases. In the example above
98105
99106 Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override.
100107
108+### Bar right anchors
109+
110+The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints hour, CPU, and memory text, and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it.
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.
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.
115+
101116 ## Remote attach
102117
103118 ```sh
@@ -15,10 +15,12 @@ go install github.com/bnema/vev@latest
15 From a checkout:15 From a checkout:
16 16
17 ```sh17 ```sh
18-make install # go install .18+make install # install vev and default bar scripts to Go's bin directory
19 go build -o vev . # local binary19 go build -o vev . # local binary
20 ```20 ```
21 21
22+`make install` places `vev-bar-top-right` and `vev-bar-bottom-right` beside the installed `vev` binary. If you install another way, copy the scripts from `scripts/` into a directory on `PATH` or replace the bar commands in config.
23+
22 ## Usage24 ## Usage
23 25
24 ```text26 ```text
@@ -69,6 +71,11 @@ switch-tab-9 = alt+9
69 # set it empty to disable process relaunch.71 # set it empty to disable process relaunch.
70 snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode72 snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
71 73
74+# Right bar anchors. Empty command values disable the matching anchor.
75+bar.top-right = vev-bar-top-right
76+bar.bottom-right = vev-bar-bottom-right
77+bar.interval = 5s
78+
72 # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.79 # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.
73 code.new-tab = CNT80 code.new-tab = CNT
74 code.new-session = CNS81 code.new-session = CNS
@@ -98,6 +105,14 @@ Configuring an action replaces all of its built-in aliases. In the example above
98 105
99 Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override.106 Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override.
100 107
108+### Bar right anchors
109+
110+The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints hour, CPU, and memory text, and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it.
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.
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.
115+
101 ## Remote attach116 ## Remote attach
102 117
103 ```sh118 ```sh
M internal/adapters/config/config.go +19 -1
@@ -15,7 +15,10 @@ import (
1515 "github.com/bnema/vev/internal/ports"
1616 )
1717
18-const pollInterval = 2 * time.Second
18+const (
19+ pollInterval = 2 * time.Second
20+ minimumBarInterval = time.Second
21+)
1922
2023 var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
2124
@@ -60,6 +63,21 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6063 continue
6164 }
6265 cfg.Theme = mode
66+ case key == "bar.top-right":
67+ cfg.Bar.TopRight = value
68+ case key == "bar.bottom-right":
69+ cfg.Bar.BottomRight = value
70+ case key == "bar.interval":
71+ interval, err := time.ParseDuration(value)
72+ if err != nil {
73+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
74+ continue
75+ }
76+ if interval < minimumBarInterval {
77+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "bar.interval below minimum \"1s\""})
78+ interval = minimumBarInterval
79+ }
80+ cfg.Bar.Interval = interval
6381 case strings.HasPrefix(key, "code."):
6482 codeKey := strings.TrimPrefix(key, "code.")
6583 if codeKey == "" {
@@ -15,7 +15,10 @@ import (
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16 )16 )
17 17
18-const pollInterval = 2 * time.Second18+const (
19+ pollInterval = 2 * time.Second
20+ minimumBarInterval = time.Second
21+)
19 22
20 var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)23 var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
21 24
@@ -60,6 +63,21 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
60 continue63 continue
61 }64 }
62 cfg.Theme = mode65 cfg.Theme = mode
66+ case key == "bar.top-right":
67+ cfg.Bar.TopRight = value
68+ case key == "bar.bottom-right":
69+ cfg.Bar.BottomRight = value
70+ case key == "bar.interval":
71+ interval, err := time.ParseDuration(value)
72+ if err != nil {
73+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
74+ continue
75+ }
76+ if interval < minimumBarInterval {
77+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "bar.interval below minimum \"1s\""})
78+ interval = minimumBarInterval
79+ }
80+ cfg.Bar.Interval = interval
63 case strings.HasPrefix(key, "code."):81 case strings.HasPrefix(key, "code."):
64 codeKey := strings.TrimPrefix(key, "code.")82 codeKey := strings.TrimPrefix(key, "code.")
65 if codeKey == "" {83 if codeKey == "" {
M internal/adapters/config/config_test.go +61 -0
@@ -91,6 +91,64 @@ func TestParse(t *testing.T) {
9191 {Line: 3, Msg: "missing key"},
9292 },
9393 },
94+ {
95+ name: "bar settings custom values",
96+ input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ Bar: domain.BarConfig{
100+ TopRight: "custom-top",
101+ BottomRight: "custom-bottom",
102+ Interval: 2 * time.Second,
103+ },
104+ BindingEntries: []domain.ConfigEntry{},
105+ Codes: map[string]string{},
106+ },
107+ },
108+ {
109+ name: "bar settings empty commands disable anchors",
110+ input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Bar: domain.BarConfig{
114+ TopRight: "",
115+ BottomRight: "",
116+ Interval: 5 * time.Second,
117+ },
118+ BindingEntries: []domain.ConfigEntry{},
119+ Codes: map[string]string{},
120+ },
121+ },
122+ {
123+ name: "bar interval invalid warns and keeps default",
124+ input: "bar.interval = nope\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Bar: domain.BarConfig{
128+ TopRight: "vev-bar-top-right",
129+ BottomRight: "vev-bar-bottom-right",
130+ Interval: 5 * time.Second,
131+ },
132+ BindingEntries: []domain.ConfigEntry{},
133+ Codes: map[string]string{},
134+ },
135+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
136+ },
137+ {
138+ name: "bar interval below minimum clamps and warns",
139+ input: "bar.interval = 100ms\n",
140+ want: domain.Config{
141+ Theme: domain.ThemeAuto,
142+ Bar: domain.BarConfig{
143+ TopRight: "vev-bar-top-right",
144+ BottomRight: "vev-bar-bottom-right",
145+ Interval: time.Second,
146+ },
147+ BindingEntries: []domain.ConfigEntry{},
148+ Codes: map[string]string{},
149+ },
150+ wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
151+ },
94152 {
95153 name: "snapshot restore processes",
96154 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
@@ -140,6 +198,9 @@ func TestParse(t *testing.T) {
140198 t.Run(tt.name, func(t *testing.T) {
141199 t.Parallel()
142200
201+ if tt.want.Bar.Interval == 0 {
202+ tt.want.Bar = domain.Defaults().Bar
203+ }
143204 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
144205 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
145206 }
@@ -91,6 +91,64 @@ func TestParse(t *testing.T) {
91 {Line: 3, Msg: "missing key"},91 {Line: 3, Msg: "missing key"},
92 },92 },
93 },93 },
94+ {
95+ name: "bar settings custom values",
96+ input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ Bar: domain.BarConfig{
100+ TopRight: "custom-top",
101+ BottomRight: "custom-bottom",
102+ Interval: 2 * time.Second,
103+ },
104+ BindingEntries: []domain.ConfigEntry{},
105+ Codes: map[string]string{},
106+ },
107+ },
108+ {
109+ name: "bar settings empty commands disable anchors",
110+ input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Bar: domain.BarConfig{
114+ TopRight: "",
115+ BottomRight: "",
116+ Interval: 5 * time.Second,
117+ },
118+ BindingEntries: []domain.ConfigEntry{},
119+ Codes: map[string]string{},
120+ },
121+ },
122+ {
123+ name: "bar interval invalid warns and keeps default",
124+ input: "bar.interval = nope\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Bar: domain.BarConfig{
128+ TopRight: "vev-bar-top-right",
129+ BottomRight: "vev-bar-bottom-right",
130+ Interval: 5 * time.Second,
131+ },
132+ BindingEntries: []domain.ConfigEntry{},
133+ Codes: map[string]string{},
134+ },
135+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
136+ },
137+ {
138+ name: "bar interval below minimum clamps and warns",
139+ input: "bar.interval = 100ms\n",
140+ want: domain.Config{
141+ Theme: domain.ThemeAuto,
142+ Bar: domain.BarConfig{
143+ TopRight: "vev-bar-top-right",
144+ BottomRight: "vev-bar-bottom-right",
145+ Interval: time.Second,
146+ },
147+ BindingEntries: []domain.ConfigEntry{},
148+ Codes: map[string]string{},
149+ },
150+ wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
151+ },
94 {152 {
95 name: "snapshot restore processes",153 name: "snapshot restore processes",
96 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",154 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
@@ -140,6 +198,9 @@ func TestParse(t *testing.T) {
140 t.Run(tt.name, func(t *testing.T) {198 t.Run(tt.name, func(t *testing.T) {
141 t.Parallel()199 t.Parallel()
142 200
201+ if tt.want.Bar.Interval == 0 {
202+ tt.want.Bar = domain.Defaults().Bar
203+ }
143 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {204 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
144 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)205 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
145 }206 }
M internal/domain/config.go +16 -1
@@ -1,5 +1,7 @@
11 package domain
22
3+import "time"
4+
35 // ThemeMode selects how vev derives its color scheme.
46 type ThemeMode int
57
@@ -35,11 +37,19 @@ func DefaultSnapshotRestoreProcesses() []string {
3537 return append([]string(nil), defaultSnapshotRestoreProcesses...)
3638 }
3739
40+// BarConfig contains user-configurable bar right-anchor settings.
41+type BarConfig struct {
42+ TopRight string
43+ BottomRight string
44+ Interval time.Duration
45+}
46+
3847 // Config is the user-editable vev configuration after parsing. Unknown binding
3948 // keys are preserved here (in BindingEntries, in file order) so the usecase
4049 // layer can decide which actions it understands.
4150 type Config struct {
4251 Theme ThemeMode
52+ Bar BarConfig
4353 BindingEntries []ConfigEntry
4454 Codes map[string]string
4555 Snapshot SnapshotConfig
@@ -55,7 +65,12 @@ type Warning struct {
5565 // Defaults returns vev's default configuration.
5666 func Defaults() Config {
5767 return Config{
58- Theme: ThemeAuto,
68+ Theme: ThemeAuto,
69+ Bar: BarConfig{
70+ TopRight: "vev-bar-top-right",
71+ BottomRight: "vev-bar-bottom-right",
72+ Interval: 5 * time.Second,
73+ },
5974 BindingEntries: []ConfigEntry{},
6075 Codes: map[string]string{},
6176 Snapshot: SnapshotConfig{
@@ -1,5 +1,7 @@
1 package domain1 package domain
2 2
3+import "time"
4+
3 // ThemeMode selects how vev derives its color scheme.5 // ThemeMode selects how vev derives its color scheme.
4 type ThemeMode int6 type ThemeMode int
5 7
@@ -35,11 +37,19 @@ func DefaultSnapshotRestoreProcesses() []string {
35 return append([]string(nil), defaultSnapshotRestoreProcesses...)37 return append([]string(nil), defaultSnapshotRestoreProcesses...)
36 }38 }
37 39
40+// BarConfig contains user-configurable bar right-anchor settings.
41+type BarConfig struct {
42+ TopRight string
43+ BottomRight string
44+ Interval time.Duration
45+}
46+
38 // Config is the user-editable vev configuration after parsing. Unknown binding47 // Config is the user-editable vev configuration after parsing. Unknown binding
39 // keys are preserved here (in BindingEntries, in file order) so the usecase48 // keys are preserved here (in BindingEntries, in file order) so the usecase
40 // layer can decide which actions it understands.49 // layer can decide which actions it understands.
41 type Config struct {50 type Config struct {
42 Theme ThemeMode51 Theme ThemeMode
52+ Bar BarConfig
43 BindingEntries []ConfigEntry53 BindingEntries []ConfigEntry
44 Codes map[string]string54 Codes map[string]string
45 Snapshot SnapshotConfig55 Snapshot SnapshotConfig
@@ -55,7 +65,12 @@ type Warning struct {
55 // Defaults returns vev's default configuration.65 // Defaults returns vev's default configuration.
56 func Defaults() Config {66 func Defaults() Config {
57 return Config{67 return Config{
58- Theme: ThemeAuto,68+ Theme: ThemeAuto,
69+ Bar: BarConfig{
70+ TopRight: "vev-bar-top-right",
71+ BottomRight: "vev-bar-bottom-right",
72+ Interval: 5 * time.Second,
73+ },
59 BindingEntries: []ConfigEntry{},74 BindingEntries: []ConfigEntry{},
60 Codes: map[string]string{},75 Codes: map[string]string{},
61 Snapshot: SnapshotConfig{76 Snapshot: SnapshotConfig{
A internal/usecase/daemon/bar_refresh.go +221 -0
@@ -0,0 +1,221 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+const minBarScriptInterval = time.Second
12+
13+type barScriptExecutor interface {
14+ run(context.Context, string, barScriptContext) (string, error)
15+}
16+
17+type barScriptConfig struct {
18+ topRight string
19+ bottomRight string
20+ interval time.Duration
21+}
22+
23+type barScriptOutputs struct {
24+ topRight string
25+ bottomRight string
26+}
27+
28+type barScriptState struct {
29+ mu sync.Mutex
30+ cfg barScriptConfig
31+ runner barScriptExecutor
32+ outputs map[domain.SessionID]barScriptOutputs
33+ lastRefresh map[domain.SessionID]time.Time
34+ running map[domain.SessionID]bool
35+}
36+
37+func effectiveBarInterval(d time.Duration) time.Duration {
38+ if d <= 0 {
39+ return 5 * time.Second
40+ }
41+ if d < minBarScriptInterval {
42+ return minBarScriptInterval
43+ }
44+ return d
45+}
46+
47+func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig {
48+ return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)}
49+}
50+
51+func (s *barScriptState) initLocked() {
52+ if s.outputs == nil {
53+ s.outputs = make(map[domain.SessionID]barScriptOutputs)
54+ }
55+ if s.lastRefresh == nil {
56+ s.lastRefresh = make(map[domain.SessionID]time.Time)
57+ }
58+ if s.running == nil {
59+ s.running = make(map[domain.SessionID]bool)
60+ }
61+}
62+
63+func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
64+ if d == nil || d.barScripts == nil || sess == nil {
65+ return "", ""
66+ }
67+ d.barScripts.mu.Lock()
68+ defer d.barScripts.mu.Unlock()
69+ out := d.barScripts.outputs[sess.id]
70+ return out.topRight, out.bottomRight
71+}
72+
73+func (d *Daemon) barScriptInterval() time.Duration {
74+ if d == nil || d.barScripts == nil {
75+ return 5 * time.Second
76+ }
77+ d.barScripts.mu.Lock()
78+ defer d.barScripts.mu.Unlock()
79+ return effectiveBarInterval(d.barScripts.cfg.interval)
80+}
81+
82+func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) {
83+ ctx := barScriptContext{Anchor: anchor}
84+ if sess == nil {
85+ return ctx, false
86+ }
87+ sess.mu.Lock()
88+ ctx.Session = sess.name
89+ active := sess.active
90+ var tb *tab
91+ if active >= 0 && active < len(sess.tabs) {
92+ tb = sess.tabs[active]
93+ }
94+ ac := sess.client
95+ ctx.PaneCWD = sess.cwd
96+ sess.mu.Unlock()
97+ if ac == nil || tb == nil {
98+ return ctx, false
99+ }
100+ tb.mu.Lock()
101+ ctx.Tab = tb.stableID
102+ ctx.Cols = tb.size.Cols
103+ if p := tb.focusedPane(); p != nil {
104+ ctx.Pane = p.stableID
105+ }
106+ tb.mu.Unlock()
107+ return ctx, true
108+}
109+
110+func (d *Daemon) barScriptPoller(ctx context.Context) {
111+ timer := d.clock.NewTimer(d.barScriptInterval())
112+ defer timer.Stop()
113+ for {
114+ select {
115+ case <-ctx.Done():
116+ return
117+ case now := <-timer.C():
118+ for _, sess := range d.sessionsSnapshot() {
119+ d.refreshBarScriptsIfDue(sess, now, false)
120+ }
121+ timer.Reset(d.barScriptInterval())
122+ }
123+ }
124+}
125+
126+func (d *Daemon) sessionsSnapshot() []*session {
127+ d.mu.Lock()
128+ defer d.mu.Unlock()
129+ sessions := make([]*session, 0, len(d.sessions))
130+ for _, sess := range d.sessions {
131+ sessions = append(sessions, sess)
132+ }
133+ return sessions
134+}
135+
136+func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
137+ if d == nil || d.barScripts == nil {
138+ return
139+ }
140+ d.barScripts.mu.Lock()
141+ defer d.barScripts.mu.Unlock()
142+ delete(d.barScripts.outputs, id)
143+ delete(d.barScripts.lastRefresh, id)
144+ delete(d.barScripts.running, id)
145+}
146+
147+func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
148+ if d == nil || d.barScripts == nil || sess == nil {
149+ return false
150+ }
151+ baseCtx, ok := d.collectBarScriptContext(sess, "")
152+ if !ok {
153+ return false
154+ }
155+ d.barScripts.mu.Lock()
156+ d.barScripts.initLocked()
157+ if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) {
158+ d.barScripts.mu.Unlock()
159+ return false
160+ }
161+ d.barScripts.running[sess.id] = true
162+ d.barScripts.lastRefresh[sess.id] = now
163+ cfg := d.barScripts.cfg
164+ runner := d.barScripts.runner
165+ d.barScripts.mu.Unlock()
166+ go d.runBarScripts(sess, runner, cfg, baseCtx)
167+ return true
168+}
169+
170+func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext) {
171+ if runner == nil {
172+ runner = barScriptRunner{baseEnv: d.baseEnv}
173+ }
174+ ctx := sess.ctx
175+ if ctx == nil {
176+ ctx = context.Background()
177+ }
178+ top, topOK := runOneBarScript(ctx, runner, cfg.topRight, base, "top-right")
179+ bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right")
180+ d.barScripts.mu.Lock()
181+ d.barScripts.initLocked()
182+ current := d.barScripts.outputs[sess.id]
183+ changed := false
184+ if topOK && current.topRight != top {
185+ current.topRight = top
186+ changed = true
187+ }
188+ if bottomOK && current.bottomRight != bottom {
189+ current.bottomRight = bottom
190+ changed = true
191+ }
192+ d.barScripts.outputs[sess.id] = current
193+ delete(d.barScripts.running, sess.id)
194+ d.barScripts.mu.Unlock()
195+ if changed {
196+ d.pokeSessionRender(sess)
197+ }
198+}
199+
200+func runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor string) (string, bool) {
201+ if command == "" {
202+ return "", true
203+ }
204+ base.Anchor = anchor
205+ out, err := runner.run(ctx, command, base)
206+ return out, err == nil
207+}
208+
209+func (d *Daemon) pokeSessionRender(sess *session) {
210+ if sess == nil {
211+ return
212+ }
213+ if tb := sess.activeTab(); tb != nil {
214+ tb.mu.Lock()
215+ p := tb.focusedPane()
216+ tb.mu.Unlock()
217+ if p != nil {
218+ signal(p.dirty)
219+ }
220+ }
221+}
@@ -0,0 +1,221 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+const minBarScriptInterval = time.Second
12+
13+type barScriptExecutor interface {
14+ run(context.Context, string, barScriptContext) (string, error)
15+}
16+
17+type barScriptConfig struct {
18+ topRight string
19+ bottomRight string
20+ interval time.Duration
21+}
22+
23+type barScriptOutputs struct {
24+ topRight string
25+ bottomRight string
26+}
27+
28+type barScriptState struct {
29+ mu sync.Mutex
30+ cfg barScriptConfig
31+ runner barScriptExecutor
32+ outputs map[domain.SessionID]barScriptOutputs
33+ lastRefresh map[domain.SessionID]time.Time
34+ running map[domain.SessionID]bool
35+}
36+
37+func effectiveBarInterval(d time.Duration) time.Duration {
38+ if d <= 0 {
39+ return 5 * time.Second
40+ }
41+ if d < minBarScriptInterval {
42+ return minBarScriptInterval
43+ }
44+ return d
45+}
46+
47+func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig {
48+ return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)}
49+}
50+
51+func (s *barScriptState) initLocked() {
52+ if s.outputs == nil {
53+ s.outputs = make(map[domain.SessionID]barScriptOutputs)
54+ }
55+ if s.lastRefresh == nil {
56+ s.lastRefresh = make(map[domain.SessionID]time.Time)
57+ }
58+ if s.running == nil {
59+ s.running = make(map[domain.SessionID]bool)
60+ }
61+}
62+
63+func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
64+ if d == nil || d.barScripts == nil || sess == nil {
65+ return "", ""
66+ }
67+ d.barScripts.mu.Lock()
68+ defer d.barScripts.mu.Unlock()
69+ out := d.barScripts.outputs[sess.id]
70+ return out.topRight, out.bottomRight
71+}
72+
73+func (d *Daemon) barScriptInterval() time.Duration {
74+ if d == nil || d.barScripts == nil {
75+ return 5 * time.Second
76+ }
77+ d.barScripts.mu.Lock()
78+ defer d.barScripts.mu.Unlock()
79+ return effectiveBarInterval(d.barScripts.cfg.interval)
80+}
81+
82+func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) {
83+ ctx := barScriptContext{Anchor: anchor}
84+ if sess == nil {
85+ return ctx, false
86+ }
87+ sess.mu.Lock()
88+ ctx.Session = sess.name
89+ active := sess.active
90+ var tb *tab
91+ if active >= 0 && active < len(sess.tabs) {
92+ tb = sess.tabs[active]
93+ }
94+ ac := sess.client
95+ ctx.PaneCWD = sess.cwd
96+ sess.mu.Unlock()
97+ if ac == nil || tb == nil {
98+ return ctx, false
99+ }
100+ tb.mu.Lock()
101+ ctx.Tab = tb.stableID
102+ ctx.Cols = tb.size.Cols
103+ if p := tb.focusedPane(); p != nil {
104+ ctx.Pane = p.stableID
105+ }
106+ tb.mu.Unlock()
107+ return ctx, true
108+}
109+
110+func (d *Daemon) barScriptPoller(ctx context.Context) {
111+ timer := d.clock.NewTimer(d.barScriptInterval())
112+ defer timer.Stop()
113+ for {
114+ select {
115+ case <-ctx.Done():
116+ return
117+ case now := <-timer.C():
118+ for _, sess := range d.sessionsSnapshot() {
119+ d.refreshBarScriptsIfDue(sess, now, false)
120+ }
121+ timer.Reset(d.barScriptInterval())
122+ }
123+ }
124+}
125+
126+func (d *Daemon) sessionsSnapshot() []*session {
127+ d.mu.Lock()
128+ defer d.mu.Unlock()
129+ sessions := make([]*session, 0, len(d.sessions))
130+ for _, sess := range d.sessions {
131+ sessions = append(sessions, sess)
132+ }
133+ return sessions
134+}
135+
136+func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
137+ if d == nil || d.barScripts == nil {
138+ return
139+ }
140+ d.barScripts.mu.Lock()
141+ defer d.barScripts.mu.Unlock()
142+ delete(d.barScripts.outputs, id)
143+ delete(d.barScripts.lastRefresh, id)
144+ delete(d.barScripts.running, id)
145+}
146+
147+func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
148+ if d == nil || d.barScripts == nil || sess == nil {
149+ return false
150+ }
151+ baseCtx, ok := d.collectBarScriptContext(sess, "")
152+ if !ok {
153+ return false
154+ }
155+ d.barScripts.mu.Lock()
156+ d.barScripts.initLocked()
157+ if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) {
158+ d.barScripts.mu.Unlock()
159+ return false
160+ }
161+ d.barScripts.running[sess.id] = true
162+ d.barScripts.lastRefresh[sess.id] = now
163+ cfg := d.barScripts.cfg
164+ runner := d.barScripts.runner
165+ d.barScripts.mu.Unlock()
166+ go d.runBarScripts(sess, runner, cfg, baseCtx)
167+ return true
168+}
169+
170+func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext) {
171+ if runner == nil {
172+ runner = barScriptRunner{baseEnv: d.baseEnv}
173+ }
174+ ctx := sess.ctx
175+ if ctx == nil {
176+ ctx = context.Background()
177+ }
178+ top, topOK := runOneBarScript(ctx, runner, cfg.topRight, base, "top-right")
179+ bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right")
180+ d.barScripts.mu.Lock()
181+ d.barScripts.initLocked()
182+ current := d.barScripts.outputs[sess.id]
183+ changed := false
184+ if topOK && current.topRight != top {
185+ current.topRight = top
186+ changed = true
187+ }
188+ if bottomOK && current.bottomRight != bottom {
189+ current.bottomRight = bottom
190+ changed = true
191+ }
192+ d.barScripts.outputs[sess.id] = current
193+ delete(d.barScripts.running, sess.id)
194+ d.barScripts.mu.Unlock()
195+ if changed {
196+ d.pokeSessionRender(sess)
197+ }
198+}
199+
200+func runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor string) (string, bool) {
201+ if command == "" {
202+ return "", true
203+ }
204+ base.Anchor = anchor
205+ out, err := runner.run(ctx, command, base)
206+ return out, err == nil
207+}
208+
209+func (d *Daemon) pokeSessionRender(sess *session) {
210+ if sess == nil {
211+ return
212+ }
213+ if tb := sess.activeTab(); tb != nil {
214+ tb.mu.Lock()
215+ p := tb.focusedPane()
216+ tb.mu.Unlock()
217+ if p != nil {
218+ signal(p.dirty)
219+ }
220+ }
221+}
A internal/usecase/daemon/bar_refresh_test.go +146 -0
@@ -0,0 +1,146 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type fakeBarRunner struct {
16+ mu sync.Mutex
17+ calls []barScriptContext
18+ outs []string
19+ errs []error
20+}
21+
22+func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) {
23+ r.mu.Lock()
24+ defer r.mu.Unlock()
25+ r.calls = append(r.calls, ctx)
26+ i := len(r.calls) - 1
27+ if i < len(r.errs) && r.errs[i] != nil {
28+ return "", r.errs[i]
29+ }
30+ if i < len(r.outs) {
31+ return r.outs[i], nil
32+ }
33+ return "", nil
34+}
35+
36+func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
37+ r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
38+ d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
39+ sess := newBarRefreshTestSession()
40+ sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}
41+
42+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
43+ waitBarRefreshIdle(t, d)
44+ require.Equal(t, 2, len(r.calls))
45+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
46+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick")
47+
48+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
49+ waitBarRefreshIdle(t, d)
50+ require.Equal(t, 4, len(r.calls))
51+ require.Equal(t, "top2", d.barStateFor(sess, "").topRight)
52+
53+ r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")}
54+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false))
55+ waitBarRefreshIdle(t, d)
56+ state := d.barStateFor(sess, "")
57+ require.Equal(t, "top2", state.topRight)
58+ require.Equal(t, "bot2", state.bottomRight)
59+}
60+
61+func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
62+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
63+ d := newBarRefreshTestDaemon(r, time.Second)
64+ sess := newBarRefreshTestSession()
65+ sess.name = "work"
66+ sess.cwd = "/repo"
67+ sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}}
68+ sess.active = 1
69+ sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
70+ active := sess.tabs[1].focusedPane()
71+ active.stableID = "pane-active"
72+
73+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
74+ waitBarRefreshIdle(t, d)
75+ require.Len(t, r.calls, 2)
76+ for _, call := range r.calls {
77+ require.Equal(t, "work", call.Session)
78+ require.Equal(t, "tab-active", call.Tab)
79+ require.Equal(t, "pane-active", call.Pane)
80+ require.Equal(t, "/repo", call.PaneCWD)
81+ require.Equal(t, 132, call.Cols)
82+ }
83+ require.Equal(t, "top-right", r.calls[0].Anchor)
84+ require.Equal(t, "bottom-right", r.calls[1].Anchor)
85+}
86+
87+func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
88+ r := &fakeBarRunner{}
89+ d := newBarRefreshTestDaemon(r, time.Second)
90+ sess := newBarRefreshTestSession()
91+
92+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
93+ require.Empty(t, r.calls)
94+}
95+
96+func TestBarScriptRefreshIsPerSession(t *testing.T) {
97+ r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
98+ d := newBarRefreshTestDaemon(r, time.Second)
99+ a := newBarRefreshTestSession()
100+ a.id = "a"
101+ a.name = "a"
102+ a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
103+ b := newBarRefreshTestSession()
104+ b.id = "b"
105+ b.name = "b"
106+ b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
107+
108+ require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
109+ waitBarRefreshIdle(t, d)
110+ require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false))
111+ waitBarRefreshIdle(t, d)
112+
113+ stateA := d.barStateFor(a, "")
114+ stateB := d.barStateFor(b, "")
115+ require.Equal(t, "top-a", stateA.topRight)
116+ require.Equal(t, "bottom-a", stateA.bottomRight)
117+ require.Equal(t, "top-b", stateB.topRight)
118+ require.Equal(t, "bottom-b", stateB.bottomRight)
119+}
120+
121+func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
122+ d := &Daemon{barScripts: &barScriptState{
123+ cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
124+ runner: r,
125+ outputs: make(map[domain.SessionID]barScriptOutputs),
126+ lastRefresh: make(map[domain.SessionID]time.Time),
127+ running: make(map[domain.SessionID]bool),
128+ }}
129+ return d
130+}
131+
132+func newBarRefreshTestSession() *session {
133+ inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
134+ active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
135+ active.tree.Focus = layout.PaneID("pane-1")
136+ return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
137+}
138+
139+func waitBarRefreshIdle(t *testing.T, d *Daemon) {
140+ t.Helper()
141+ require.Eventually(t, func() bool {
142+ d.barScripts.mu.Lock()
143+ defer d.barScripts.mu.Unlock()
144+ return len(d.barScripts.running) == 0
145+ }, time.Second, time.Millisecond)
146+}
@@ -0,0 +1,146 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type fakeBarRunner struct {
16+ mu sync.Mutex
17+ calls []barScriptContext
18+ outs []string
19+ errs []error
20+}
21+
22+func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) {
23+ r.mu.Lock()
24+ defer r.mu.Unlock()
25+ r.calls = append(r.calls, ctx)
26+ i := len(r.calls) - 1
27+ if i < len(r.errs) && r.errs[i] != nil {
28+ return "", r.errs[i]
29+ }
30+ if i < len(r.outs) {
31+ return r.outs[i], nil
32+ }
33+ return "", nil
34+}
35+
36+func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
37+ r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
38+ d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
39+ sess := newBarRefreshTestSession()
40+ sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}
41+
42+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
43+ waitBarRefreshIdle(t, d)
44+ require.Equal(t, 2, len(r.calls))
45+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
46+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick")
47+
48+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
49+ waitBarRefreshIdle(t, d)
50+ require.Equal(t, 4, len(r.calls))
51+ require.Equal(t, "top2", d.barStateFor(sess, "").topRight)
52+
53+ r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")}
54+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false))
55+ waitBarRefreshIdle(t, d)
56+ state := d.barStateFor(sess, "")
57+ require.Equal(t, "top2", state.topRight)
58+ require.Equal(t, "bot2", state.bottomRight)
59+}
60+
61+func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
62+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
63+ d := newBarRefreshTestDaemon(r, time.Second)
64+ sess := newBarRefreshTestSession()
65+ sess.name = "work"
66+ sess.cwd = "/repo"
67+ sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}}
68+ sess.active = 1
69+ sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
70+ active := sess.tabs[1].focusedPane()
71+ active.stableID = "pane-active"
72+
73+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
74+ waitBarRefreshIdle(t, d)
75+ require.Len(t, r.calls, 2)
76+ for _, call := range r.calls {
77+ require.Equal(t, "work", call.Session)
78+ require.Equal(t, "tab-active", call.Tab)
79+ require.Equal(t, "pane-active", call.Pane)
80+ require.Equal(t, "/repo", call.PaneCWD)
81+ require.Equal(t, 132, call.Cols)
82+ }
83+ require.Equal(t, "top-right", r.calls[0].Anchor)
84+ require.Equal(t, "bottom-right", r.calls[1].Anchor)
85+}
86+
87+func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
88+ r := &fakeBarRunner{}
89+ d := newBarRefreshTestDaemon(r, time.Second)
90+ sess := newBarRefreshTestSession()
91+
92+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
93+ require.Empty(t, r.calls)
94+}
95+
96+func TestBarScriptRefreshIsPerSession(t *testing.T) {
97+ r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
98+ d := newBarRefreshTestDaemon(r, time.Second)
99+ a := newBarRefreshTestSession()
100+ a.id = "a"
101+ a.name = "a"
102+ a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
103+ b := newBarRefreshTestSession()
104+ b.id = "b"
105+ b.name = "b"
106+ b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
107+
108+ require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
109+ waitBarRefreshIdle(t, d)
110+ require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false))
111+ waitBarRefreshIdle(t, d)
112+
113+ stateA := d.barStateFor(a, "")
114+ stateB := d.barStateFor(b, "")
115+ require.Equal(t, "top-a", stateA.topRight)
116+ require.Equal(t, "bottom-a", stateA.bottomRight)
117+ require.Equal(t, "top-b", stateB.topRight)
118+ require.Equal(t, "bottom-b", stateB.bottomRight)
119+}
120+
121+func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
122+ d := &Daemon{barScripts: &barScriptState{
123+ cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
124+ runner: r,
125+ outputs: make(map[domain.SessionID]barScriptOutputs),
126+ lastRefresh: make(map[domain.SessionID]time.Time),
127+ running: make(map[domain.SessionID]bool),
128+ }}
129+ return d
130+}
131+
132+func newBarRefreshTestSession() *session {
133+ inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
134+ active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
135+ active.tree.Focus = layout.PaneID("pane-1")
136+ return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
137+}
138+
139+func waitBarRefreshIdle(t *testing.T, d *Daemon) {
140+ t.Helper()
141+ require.Eventually(t, func() bool {
142+ d.barScripts.mu.Lock()
143+ defer d.barScripts.mu.Unlock()
144+ return len(d.barScripts.running) == 0
145+ }, time.Second, time.Millisecond)
146+}
A internal/usecase/daemon/bar_script.go +165 -0
@@ -0,0 +1,165 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+)
14+
15+const (
16+ barScriptOutputLimit = 1024
17+ barScriptDisplayLimit = 256
18+ barScriptTimeout = time.Second
19+)
20+
21+type barScriptContext struct {
22+ Anchor string
23+ Session string
24+ Tab string
25+ Pane string
26+ PaneCWD string
27+ Cols int
28+}
29+
30+func (c barScriptContext) env(base []string) []string {
31+ vars := map[string]string{
32+ "VEV_ANCHOR": c.Anchor,
33+ "VEV_SESSION": c.Session,
34+ "VEV_TAB": c.Tab,
35+ "VEV_PANE": c.Pane,
36+ "VEV_PANE_CWD": c.PaneCWD,
37+ "VEV_COLS": strconv.Itoa(c.Cols),
38+ }
39+ out := make([]string, 0, len(base)+len(vars))
40+ for _, entry := range base {
41+ key, _, ok := strings.Cut(entry, "=")
42+ if ok {
43+ if _, replace := vars[key]; replace {
44+ continue
45+ }
46+ }
47+ out = append(out, entry)
48+ }
49+ for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} {
50+ out = append(out, key+"="+vars[key])
51+ }
52+ return out
53+}
54+
55+type barScriptRunner struct {
56+ timeout time.Duration
57+ baseEnv []string
58+}
59+
60+func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) {
61+ if strings.TrimSpace(command) == "" {
62+ return "", nil
63+ }
64+ timeout := r.timeout
65+ if timeout <= 0 {
66+ timeout = barScriptTimeout
67+ }
68+ ctx, cancel := context.WithTimeout(ctx, timeout)
69+ defer cancel()
70+
71+ cmd := exec.CommandContext(ctx, "sh", "-c", command)
72+ cmd.Env = scriptCtx.env(r.baseEnv)
73+ var stdout boundedBuffer
74+ stdout.limit = barScriptOutputLimit
75+ var stderr bytes.Buffer
76+ cmd.Stdout = &stdout
77+ cmd.Stderr = &stderr
78+
79+ err := cmd.Run()
80+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
81+ return "", context.DeadlineExceeded
82+ }
83+ text := sanitizeBarScriptOutput(stdout.Bytes(), barScriptOutputLimit)
84+ if err != nil {
85+ return text, err
86+ }
87+ return text, nil
88+}
89+
90+type boundedBuffer struct {
91+ buf bytes.Buffer
92+ limit int
93+}
94+
95+func (b *boundedBuffer) Write(p []byte) (int, error) {
96+ if b.limit <= 0 {
97+ return len(p), nil
98+ }
99+ remaining := b.limit - b.buf.Len()
100+ if remaining > 0 {
101+ if len(p) < remaining {
102+ remaining = len(p)
103+ }
104+ _, _ = b.buf.Write(p[:remaining])
105+ }
106+ return len(p), nil
107+}
108+
109+func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
110+
111+func sanitizeBarScriptOutput(raw []byte, limit int) string {
112+ if limit <= 0 || limit > len(raw) {
113+ limit = len(raw)
114+ }
115+ raw = raw[:limit]
116+ if i := bytes.IndexAny(raw, "\r\n"); i >= 0 {
117+ raw = raw[:i]
118+ }
119+ text := strings.ToValidUTF8(string(raw), "")
120+ text = stripANSIAndControls(text)
121+ if len(text) <= barScriptDisplayLimit {
122+ return text
123+ }
124+ return trimUTF8Bytes(text, barScriptDisplayLimit)
125+}
126+
127+func stripANSIAndControls(s string) string {
128+ var out strings.Builder
129+ for i := 0; i < len(s); {
130+ r, size := utf8.DecodeRuneInString(s[i:])
131+ if r == '\x1b' {
132+ i += size
133+ if i < len(s) && s[i] == '[' {
134+ i++
135+ for i < len(s) {
136+ c := s[i]
137+ i++
138+ if c >= 0x40 && c <= 0x7e {
139+ break
140+ }
141+ }
142+ }
143+ continue
144+ }
145+ if r < 0x20 || r == 0x7f {
146+ i += size
147+ continue
148+ }
149+ out.WriteRune(r)
150+ i += size
151+ }
152+ return out.String()
153+}
154+
155+func trimUTF8Bytes(s string, limit int) string {
156+ if len(s) <= limit {
157+ return s
158+ }
159+ for limit > 0 && !utf8.ValidString(s[:limit]) {
160+ limit--
161+ }
162+ return s[:limit]
163+}
164+
165+var _ io.Writer = (*boundedBuffer)(nil)
@@ -0,0 +1,165 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+)
14+
15+const (
16+ barScriptOutputLimit = 1024
17+ barScriptDisplayLimit = 256
18+ barScriptTimeout = time.Second
19+)
20+
21+type barScriptContext struct {
22+ Anchor string
23+ Session string
24+ Tab string
25+ Pane string
26+ PaneCWD string
27+ Cols int
28+}
29+
30+func (c barScriptContext) env(base []string) []string {
31+ vars := map[string]string{
32+ "VEV_ANCHOR": c.Anchor,
33+ "VEV_SESSION": c.Session,
34+ "VEV_TAB": c.Tab,
35+ "VEV_PANE": c.Pane,
36+ "VEV_PANE_CWD": c.PaneCWD,
37+ "VEV_COLS": strconv.Itoa(c.Cols),
38+ }
39+ out := make([]string, 0, len(base)+len(vars))
40+ for _, entry := range base {
41+ key, _, ok := strings.Cut(entry, "=")
42+ if ok {
43+ if _, replace := vars[key]; replace {
44+ continue
45+ }
46+ }
47+ out = append(out, entry)
48+ }
49+ for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} {
50+ out = append(out, key+"="+vars[key])
51+ }
52+ return out
53+}
54+
55+type barScriptRunner struct {
56+ timeout time.Duration
57+ baseEnv []string
58+}
59+
60+func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) {
61+ if strings.TrimSpace(command) == "" {
62+ return "", nil
63+ }
64+ timeout := r.timeout
65+ if timeout <= 0 {
66+ timeout = barScriptTimeout
67+ }
68+ ctx, cancel := context.WithTimeout(ctx, timeout)
69+ defer cancel()
70+
71+ cmd := exec.CommandContext(ctx, "sh", "-c", command)
72+ cmd.Env = scriptCtx.env(r.baseEnv)
73+ var stdout boundedBuffer
74+ stdout.limit = barScriptOutputLimit
75+ var stderr bytes.Buffer
76+ cmd.Stdout = &stdout
77+ cmd.Stderr = &stderr
78+
79+ err := cmd.Run()
80+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
81+ return "", context.DeadlineExceeded
82+ }
83+ text := sanitizeBarScriptOutput(stdout.Bytes(), barScriptOutputLimit)
84+ if err != nil {
85+ return text, err
86+ }
87+ return text, nil
88+}
89+
90+type boundedBuffer struct {
91+ buf bytes.Buffer
92+ limit int
93+}
94+
95+func (b *boundedBuffer) Write(p []byte) (int, error) {
96+ if b.limit <= 0 {
97+ return len(p), nil
98+ }
99+ remaining := b.limit - b.buf.Len()
100+ if remaining > 0 {
101+ if len(p) < remaining {
102+ remaining = len(p)
103+ }
104+ _, _ = b.buf.Write(p[:remaining])
105+ }
106+ return len(p), nil
107+}
108+
109+func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
110+
111+func sanitizeBarScriptOutput(raw []byte, limit int) string {
112+ if limit <= 0 || limit > len(raw) {
113+ limit = len(raw)
114+ }
115+ raw = raw[:limit]
116+ if i := bytes.IndexAny(raw, "\r\n"); i >= 0 {
117+ raw = raw[:i]
118+ }
119+ text := strings.ToValidUTF8(string(raw), "")
120+ text = stripANSIAndControls(text)
121+ if len(text) <= barScriptDisplayLimit {
122+ return text
123+ }
124+ return trimUTF8Bytes(text, barScriptDisplayLimit)
125+}
126+
127+func stripANSIAndControls(s string) string {
128+ var out strings.Builder
129+ for i := 0; i < len(s); {
130+ r, size := utf8.DecodeRuneInString(s[i:])
131+ if r == '\x1b' {
132+ i += size
133+ if i < len(s) && s[i] == '[' {
134+ i++
135+ for i < len(s) {
136+ c := s[i]
137+ i++
138+ if c >= 0x40 && c <= 0x7e {
139+ break
140+ }
141+ }
142+ }
143+ continue
144+ }
145+ if r < 0x20 || r == 0x7f {
146+ i += size
147+ continue
148+ }
149+ out.WriteRune(r)
150+ i += size
151+ }
152+ return out.String()
153+}
154+
155+func trimUTF8Bytes(s string, limit int) string {
156+ if len(s) <= limit {
157+ return s
158+ }
159+ for limit > 0 && !utf8.ValidString(s[:limit]) {
160+ limit--
161+ }
162+ return s[:limit]
163+}
164+
165+var _ io.Writer = (*boundedBuffer)(nil)
A internal/usecase/daemon/bar_script_test.go +110 -0
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+func TestBarScriptSanitizeOutput(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ raw string
16+ limit int
17+ want string
18+ }{
19+ {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
20+ {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
21+ {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
22+ {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
23+ {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
24+ {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want {
30+ t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestBarScriptContextEnv(t *testing.T) {
37+ base := []string{
38+ "PATH=/bin",
39+ "VEV_ANCHOR=old",
40+ "VEV_SESSION=old",
41+ "VEV_TAB=old",
42+ "VEV_PANE=old",
43+ "VEV_PANE_CWD=/old",
44+ "VEV_COLS=1",
45+ }
46+ ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}
47+ got := ctx.env(base)
48+
49+ want := map[string]string{
50+ "VEV_ANCHOR": "top-right",
51+ "VEV_SESSION": "work",
52+ "VEV_TAB": "2",
53+ "VEV_PANE": "pane-3",
54+ "VEV_PANE_CWD": "/repo",
55+ "VEV_COLS": "120",
56+ }
57+ seen := map[string]int{}
58+ for _, entry := range got {
59+ key, val, ok := strings.Cut(entry, "=")
60+ if !ok {
61+ continue
62+ }
63+ if _, isVEV := want[key]; isVEV {
64+ seen[key]++
65+ if val != want[key] {
66+ t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got)
67+ }
68+ }
69+ }
70+ for key := range want {
71+ if seen[key] != 1 {
72+ t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got)
73+ }
74+ }
75+}
76+
77+func TestBarScriptRunner(t *testing.T) {
78+ runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}
79+ got, err := runner.run(context.Background(), "", barScriptContext{})
80+ if err != nil || got != "" {
81+ t.Fatalf("empty command = %q, %v; want empty nil", got, err)
82+ }
83+
84+ got, err = runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{})
85+ if err != nil {
86+ t.Fatalf("run command: %v", err)
87+ }
88+ if got != "ok" {
89+ t.Fatalf("run command = %q, want ok", got)
90+ }
91+
92+ got, err = runner.run(context.Background(), "sleep 1; printf late", barScriptContext{})
93+ if !errors.Is(err, context.DeadlineExceeded) {
94+ t.Fatalf("timeout err = %v, want DeadlineExceeded", err)
95+ }
96+ if got != "" {
97+ t.Fatalf("timeout output = %q, want empty", got)
98+ }
99+}
100+
101+func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
102+ runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()}
103+ got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{})
104+ if err != nil {
105+ t.Fatalf("run bounded output: %v", err)
106+ }
107+ if len(got) > barScriptDisplayLimit {
108+ t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
109+ }
110+}
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+func TestBarScriptSanitizeOutput(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ raw string
16+ limit int
17+ want string
18+ }{
19+ {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
20+ {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
21+ {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
22+ {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
23+ {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
24+ {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want {
30+ t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestBarScriptContextEnv(t *testing.T) {
37+ base := []string{
38+ "PATH=/bin",
39+ "VEV_ANCHOR=old",
40+ "VEV_SESSION=old",
41+ "VEV_TAB=old",
42+ "VEV_PANE=old",
43+ "VEV_PANE_CWD=/old",
44+ "VEV_COLS=1",
45+ }
46+ ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}
47+ got := ctx.env(base)
48+
49+ want := map[string]string{
50+ "VEV_ANCHOR": "top-right",
51+ "VEV_SESSION": "work",
52+ "VEV_TAB": "2",
53+ "VEV_PANE": "pane-3",
54+ "VEV_PANE_CWD": "/repo",
55+ "VEV_COLS": "120",
56+ }
57+ seen := map[string]int{}
58+ for _, entry := range got {
59+ key, val, ok := strings.Cut(entry, "=")
60+ if !ok {
61+ continue
62+ }
63+ if _, isVEV := want[key]; isVEV {
64+ seen[key]++
65+ if val != want[key] {
66+ t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got)
67+ }
68+ }
69+ }
70+ for key := range want {
71+ if seen[key] != 1 {
72+ t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got)
73+ }
74+ }
75+}
76+
77+func TestBarScriptRunner(t *testing.T) {
78+ runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}
79+ got, err := runner.run(context.Background(), "", barScriptContext{})
80+ if err != nil || got != "" {
81+ t.Fatalf("empty command = %q, %v; want empty nil", got, err)
82+ }
83+
84+ got, err = runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{})
85+ if err != nil {
86+ t.Fatalf("run command: %v", err)
87+ }
88+ if got != "ok" {
89+ t.Fatalf("run command = %q, want ok", got)
90+ }
91+
92+ got, err = runner.run(context.Background(), "sleep 1; printf late", barScriptContext{})
93+ if !errors.Is(err, context.DeadlineExceeded) {
94+ t.Fatalf("timeout err = %v, want DeadlineExceeded", err)
95+ }
96+ if got != "" {
97+ t.Fatalf("timeout output = %q, want empty", got)
98+ }
99+}
100+
101+func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
102+ runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()}
103+ got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{})
104+ if err != nil {
105+ t.Fatalf("run bounded output: %v", err)
106+ }
107+ if len(got) > barScriptDisplayLimit {
108+ t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
109+ }
110+}
M internal/usecase/daemon/client.go +2 -0
@@ -389,6 +389,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
389389 d.resize(sess, ac, clientSize)
390390 return
391391 }
392+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
392393 d.paint(sess, ac, true)
393394 }
394395
@@ -539,6 +540,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
539540 }
540541 markSnapshotDirty(sess)
541542 if ac != nil {
543+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
542544 d.paint(sess, ac, true)
543545 }
544546 }
@@ -389,6 +389,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
389 d.resize(sess, ac, clientSize)389 d.resize(sess, ac, clientSize)
390 return390 return
391 }391 }
392+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
392 d.paint(sess, ac, true)393 d.paint(sess, ac, true)
393 }394 }
394 395
@@ -539,6 +540,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
539 }540 }
540 markSnapshotDirty(sess)541 markSnapshotDirty(sess)
541 if ac != nil {542 if ac != nil {
543+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
542 d.paint(sess, ac, true)544 d.paint(sess, ac, true)
543 }545 }
544 }546 }
M internal/usecase/daemon/config.go +9 -0
@@ -6,6 +6,7 @@ import (
66 "regexp"
77 "sort"
88 "strings"
9+ "time"
910
1011 "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/internal/usecase/command"
@@ -27,6 +28,14 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
2728 d.codeOverrides.Store(&overrides)
2829 d.restoreProcessAllowlist.Store(&allowlist)
2930 d.themeMode.Store(uint32(cfg.Theme))
31+ if d.barScripts != nil {
32+ d.barScripts.mu.Lock()
33+ d.barScripts.cfg = barConfigFromDomain(cfg.Bar)
34+ d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
35+ d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
36+ d.barScripts.running = make(map[domain.SessionID]bool)
37+ d.barScripts.mu.Unlock()
38+ }
3039 d.reapplyThemeAllSessions()
3140 d.repaintAllAttachedClients()
3241 }
@@ -6,6 +6,7 @@ import (
6 "regexp"6 "regexp"
7 "sort"7 "sort"
8 "strings"8 "strings"
9+ "time"
9 10
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/usecase/command"12 "github.com/bnema/vev/internal/usecase/command"
@@ -27,6 +28,14 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
27 d.codeOverrides.Store(&overrides)28 d.codeOverrides.Store(&overrides)
28 d.restoreProcessAllowlist.Store(&allowlist)29 d.restoreProcessAllowlist.Store(&allowlist)
29 d.themeMode.Store(uint32(cfg.Theme))30 d.themeMode.Store(uint32(cfg.Theme))
31+ if d.barScripts != nil {
32+ d.barScripts.mu.Lock()
33+ d.barScripts.cfg = barConfigFromDomain(cfg.Bar)
34+ d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
35+ d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
36+ d.barScripts.running = make(map[domain.SessionID]bool)
37+ d.barScripts.mu.Unlock()
38+ }
30 d.reapplyThemeAllSessions()39 d.reapplyThemeAllSessions()
31 d.repaintAllAttachedClients()40 d.repaintAllAttachedClients()
32 }41 }
M internal/usecase/daemon/copymode.go +1 -1
@@ -441,7 +441,7 @@ func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.
441441 width, screenRows = tb.size.Cols, tb.size.Rows
442442 }
443443 frame := renderer.NewFrame(width, screenRows+2)
444- drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)
444+ drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles)
445445 base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
446446 for y := range screenRows {
447447 copy(frame.Row(y+1), base.Row(y))
@@ -441,7 +441,7 @@ func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.
441 width, screenRows = tb.size.Cols, tb.size.Rows441 width, screenRows = tb.size.Cols, tb.size.Rows
442 }442 }
443 frame := renderer.NewFrame(width, screenRows+2)443 frame := renderer.NewFrame(width, screenRows+2)
444- drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)444+ drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles)
445 base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)445 base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
446 for y := range screenRows {446 for y := range screenRows {
447 copy(frame.Row(y+1), base.Row(y))447 copy(frame.Row(y+1), base.Row(y))
M internal/usecase/daemon/daemon.go +11 -0
@@ -116,6 +116,7 @@ type Daemon struct {
116116 codeOverrides atomic.Pointer[map[string]string]
117117 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
118118 themeMode atomic.Uint32
119+ barScripts *barScriptState
119120 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
120121 // (see clipboard.go); empty means use os.TempDir().
121122 tempDir string
@@ -255,6 +256,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
255256 done: make(chan struct{}),
256257 restoreDone: make(chan struct{}),
257258 animWake: make(chan struct{}, 1),
259+ barScripts: &barScriptState{
260+ cfg: barConfigFromDomain(domain.Defaults().Bar),
261+ runner: barScriptRunner{baseEnv: os.Environ()},
262+ outputs: make(map[domain.SessionID]barScriptOutputs),
263+ lastRefresh: make(map[domain.SessionID]time.Time),
264+ running: make(map[domain.SessionID]bool),
265+ },
258266 }
259267 for _, o := range opts {
260268 o(d)
@@ -303,6 +311,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
303311 d.sessWg.Go(func() {
304312 d.attentionAnimator(d.serveCtx)
305313 })
314+ d.sessWg.Go(func() {
315+ d.barScriptPoller(d.serveCtx)
316+ })
306317 if d.persistEnabled && d.procCwd != nil {
307318 d.sessWg.Go(func() {
308319 d.cwdSampler(d.serveCtx)
@@ -116,6 +116,7 @@ type Daemon struct {
116 codeOverrides atomic.Pointer[map[string]string]116 codeOverrides atomic.Pointer[map[string]string]
117 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]117 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
118 themeMode atomic.Uint32118 themeMode atomic.Uint32
119+ barScripts *barScriptState
119 // tempDir overrides os.TempDir() for clipboard-image-transfer writes120 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
120 // (see clipboard.go); empty means use os.TempDir().121 // (see clipboard.go); empty means use os.TempDir().
121 tempDir string122 tempDir string
@@ -255,6 +256,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
255 done: make(chan struct{}),256 done: make(chan struct{}),
256 restoreDone: make(chan struct{}),257 restoreDone: make(chan struct{}),
257 animWake: make(chan struct{}, 1),258 animWake: make(chan struct{}, 1),
259+ barScripts: &barScriptState{
260+ cfg: barConfigFromDomain(domain.Defaults().Bar),
261+ runner: barScriptRunner{baseEnv: os.Environ()},
262+ outputs: make(map[domain.SessionID]barScriptOutputs),
263+ lastRefresh: make(map[domain.SessionID]time.Time),
264+ running: make(map[domain.SessionID]bool),
265+ },
258 }266 }
259 for _, o := range opts {267 for _, o := range opts {
260 o(d)268 o(d)
@@ -303,6 +311,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
303 d.sessWg.Go(func() {311 d.sessWg.Go(func() {
304 d.attentionAnimator(d.serveCtx)312 d.attentionAnimator(d.serveCtx)
305 })313 })
314+ d.sessWg.Go(func() {
315+ d.barScriptPoller(d.serveCtx)
316+ })
306 if d.persistEnabled && d.procCwd != nil {317 if d.persistEnabled && d.procCwd != nil {
307 d.sessWg.Go(func() {318 d.sessWg.Go(func() {
308 d.cwdSampler(d.serveCtx)319 d.cwdSampler(d.serveCtx)
M internal/usecase/daemon/render.go +2 -1
@@ -252,6 +252,7 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
252252 p.mu.Unlock()
253253 return
254254 }
255+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
255256 d.paint(sess, ac, false)
256257 }
257258
@@ -502,7 +503,7 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
502503 }
503504 frame := renderer.NewFrame(width, screenRows+2)
504505 topBar := frame.Row(0)
505- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles)
506+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
506507 contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap)
507508 for y := range screenRows {
508509 copy(frame.Row(y+1), contentFrame.Row(y))
@@ -252,6 +252,7 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
252 p.mu.Unlock()252 p.mu.Unlock()
253 return253 return
254 }254 }
255+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
255 d.paint(sess, ac, false)256 d.paint(sess, ac, false)
256 }257 }
257 258
@@ -502,7 +503,7 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
502 }503 }
503 frame := renderer.NewFrame(width, screenRows+2)504 frame := renderer.NewFrame(width, screenRows+2)
504 topBar := frame.Row(0)505 topBar := frame.Row(0)
505- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles)506+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
506 contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap)507 contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap)
507 for y := range screenRows {508 for y := range screenRows {
508 copy(frame.Row(y+1), contentFrame.Row(y))509 copy(frame.Row(y+1), contentFrame.Row(y))
M internal/usecase/daemon/session.go +1 -0
@@ -538,6 +538,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
538538 return nil
539539 }
540540 delete(d.sessions, sess.id)
541+ d.clearBarScriptsForSession(sess.id)
541542 d.purgeParkedForSessionLocked(sess)
542543 sess.mu.Lock()
543544 stoppedName := sess.name
@@ -538,6 +538,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
538 return nil538 return nil
539 }539 }
540 delete(d.sessions, sess.id)540 delete(d.sessions, sess.id)
541+ d.clearBarScriptsForSession(sess.id)
541 d.purgeParkedForSessionLocked(sess)542 d.purgeParkedForSessionLocked(sess)
542 sess.mu.Lock()543 sess.mu.Lock()
543 stoppedName := sess.name544 stoppedName := sess.name
M internal/usecase/daemon/status.go +20 -3
@@ -58,7 +58,7 @@ func pulseStyle(frame int) (renderer.Style, bool) {
5858 return style, true
5959 }
6060
61-func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, styles themeStyles) {
61+func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) {
6262 clearStatusRow(row)
6363 x := 0
6464 for _, w := range status.tabs {
@@ -73,6 +73,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s
7373 }
7474 writeStatusText(row, &x, " ", style)
7575 }
76+ drawRightPlainText(row, topRight, x, styles.statusBar)
7677 }
7778
7879 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
@@ -80,7 +81,8 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
8081 x := 0
8182 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
8283
83- fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback)
84+ rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
85+ fittedMRU := fitMRU(state.mru, len(row), x, rightText)
8486 for i, sess := range fittedMRU {
8587 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
8688 name := sess.name
@@ -94,7 +96,17 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
9496 }
9597 writeStatusText(row, &x, " ", style)
9698 }
97- drawRightPlainText(row, state.copyFeedback, x, styles.statusBar)
99+ drawRightPlainText(row, rightText, x, styles.statusBar)
100+}
101+
102+func composeBottomRightText(scriptText, copyFeedback string) string {
103+ if scriptText == "" {
104+ return copyFeedback
105+ }
106+ if copyFeedback == "" {
107+ return scriptText
108+ }
109+ return scriptText + " " + copyFeedback
98110 }
99111
100112 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
@@ -113,6 +125,8 @@ func clearStatusRow(row []renderer.Cell) {
113125
114126 type barState struct {
115127 status statusSnapshot
128+ topRight string
129+ bottomRight string
116130 copyFeedback string
117131 mru []mruSession
118132 attentionFrame int
@@ -165,6 +179,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
165179 if cur != nil {
166180 state.status = cur.statusSegments()
167181 }
182+ if d != nil {
183+ state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
184+ }
168185 if d == nil {
169186 return state
170187 }
@@ -58,7 +58,7 @@ func pulseStyle(frame int) (renderer.Style, bool) {
58 return style, true58 return style, true
59 }59 }
60 60
61-func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, styles themeStyles) {61+func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) {
62 clearStatusRow(row)62 clearStatusRow(row)
63 x := 063 x := 0
64 for _, w := range status.tabs {64 for _, w := range status.tabs {
@@ -73,6 +73,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s
73 }73 }
74 writeStatusText(row, &x, " ", style)74 writeStatusText(row, &x, " ", style)
75 }75 }
76+ drawRightPlainText(row, topRight, x, styles.statusBar)
76 }77 }
77 78
78 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {79 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
@@ -80,7 +81,8 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
80 x := 081 x := 0
81 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)82 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
82 83
83- fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback)84+ rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
85+ fittedMRU := fitMRU(state.mru, len(row), x, rightText)
84 for i, sess := range fittedMRU {86 for i, sess := range fittedMRU {
85 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))87 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
86 name := sess.name88 name := sess.name
@@ -94,7 +96,17 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
94 }96 }
95 writeStatusText(row, &x, " ", style)97 writeStatusText(row, &x, " ", style)
96 }98 }
97- drawRightPlainText(row, state.copyFeedback, x, styles.statusBar)99+ drawRightPlainText(row, rightText, x, styles.statusBar)
100+}
101+
102+func composeBottomRightText(scriptText, copyFeedback string) string {
103+ if scriptText == "" {
104+ return copyFeedback
105+ }
106+ if copyFeedback == "" {
107+ return scriptText
108+ }
109+ return scriptText + " " + copyFeedback
98 }110 }
99 111
100 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {112 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
@@ -113,6 +125,8 @@ func clearStatusRow(row []renderer.Cell) {
113 125
114 type barState struct {126 type barState struct {
115 status statusSnapshot127 status statusSnapshot
128+ topRight string
129+ bottomRight string
116 copyFeedback string130 copyFeedback string
117 mru []mruSession131 mru []mruSession
118 attentionFrame int132 attentionFrame int
@@ -165,6 +179,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
165 if cur != nil {179 if cur != nil {
166 state.status = cur.statusSegments()180 state.status = cur.statusSegments()
167 }181 }
182+ if d != nil {
183+ state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
184+ }
168 if d == nil {185 if d == nil {
169 return state186 return state
170 }187 }
M internal/usecase/daemon/status_test.go +62 -0
@@ -398,6 +398,24 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
398398 require.Equal(t, " 0* ", rowText(frame.Row(3)))
399399 }
400400
401+func TestTopBarRightAnchorRendersFlushRightWhenFullyFits(t *testing.T) {
402+ row := make([]renderer.Cell, 20)
403+ status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}}
404+
405+ drawTopBarSnapshot(row, status, 0, "14:32", resolveThemeStyles(nil))
406+
407+ require.Equal(t, " 1 14:32", rowText(row))
408+}
409+
410+func TestTopBarRightAnchorHidesOnOverlap(t *testing.T) {
411+ row := make([]renderer.Cell, 10)
412+ status := statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}}
413+
414+ drawTopBarSnapshot(row, status, 0, "12345", resolveThemeStyles(nil))
415+
416+ require.Equal(t, " 1 2 ", rowText(row))
417+}
418+
401419 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
402420 p, releasePTY := newBlockingPTY(t)
403421 _, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -644,6 +662,50 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
644662 }
645663 }
646664
665+func TestStatusBarBottomRightScriptTextAndCopyFeedback(t *testing.T) {
666+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}}
667+ row := make([]renderer.Cell, 32)
668+
669+ drawStatusBarState(row, state, resolveThemeStyles(nil))
670+
671+ text := rowText(row)
672+ require.Contains(t, text, " a")
673+ require.True(t, strings.HasSuffix(text, " main ↑3 * copied"), text)
674+}
675+
676+func TestStatusBarBottomRightHidesOnOverlap(t *testing.T) {
677+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
678+ row := make([]renderer.Cell, 16)
679+
680+ drawStatusBarState(row, state, resolveThemeStyles(nil))
681+
682+ text := rowText(row)
683+ require.Equal(t, " cur ", text)
684+ require.NotContains(t, text, "main")
685+ require.NotContains(t, text, "copied")
686+}
687+
688+func TestStatusBarEmptyBottomRightScriptKeepsCopyFeedbackBehavior(t *testing.T) {
689+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
690+ row := make([]renderer.Cell, 12)
691+
692+ drawStatusBarState(row, state, resolveThemeStyles(nil))
693+
694+ require.Equal(t, " cur copied", rowText(row))
695+}
696+
697+func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) {
698+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
699+ row := make([]renderer.Cell, 24)
700+
701+ drawStatusBarState(row, state, resolveThemeStyles(nil))
702+
703+ text := rowText(row)
704+ require.Contains(t, text, " fresh")
705+ require.NotContains(t, text, "middle")
706+ require.True(t, strings.HasSuffix(text, " git"), text)
707+}
708+
647709 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
648710 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
649711 row := make([]renderer.Cell, 20)
@@ -398,6 +398,24 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
398 require.Equal(t, " 0* ", rowText(frame.Row(3)))398 require.Equal(t, " 0* ", rowText(frame.Row(3)))
399 }399 }
400 400
401+func TestTopBarRightAnchorRendersFlushRightWhenFullyFits(t *testing.T) {
402+ row := make([]renderer.Cell, 20)
403+ status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}}
404+
405+ drawTopBarSnapshot(row, status, 0, "14:32", resolveThemeStyles(nil))
406+
407+ require.Equal(t, " 1 14:32", rowText(row))
408+}
409+
410+func TestTopBarRightAnchorHidesOnOverlap(t *testing.T) {
411+ row := make([]renderer.Cell, 10)
412+ status := statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}}
413+
414+ drawTopBarSnapshot(row, status, 0, "12345", resolveThemeStyles(nil))
415+
416+ require.Equal(t, " 1 2 ", rowText(row))
417+}
418+
401 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {419 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
402 p, releasePTY := newBlockingPTY(t)420 p, releasePTY := newBlockingPTY(t)
403 _, sess, _, _ := newManualSessionWithPTYs(t, p)421 _, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -644,6 +662,50 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
644 }662 }
645 }663 }
646 664
665+func TestStatusBarBottomRightScriptTextAndCopyFeedback(t *testing.T) {
666+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}}
667+ row := make([]renderer.Cell, 32)
668+
669+ drawStatusBarState(row, state, resolveThemeStyles(nil))
670+
671+ text := rowText(row)
672+ require.Contains(t, text, " a")
673+ require.True(t, strings.HasSuffix(text, " main ↑3 * copied"), text)
674+}
675+
676+func TestStatusBarBottomRightHidesOnOverlap(t *testing.T) {
677+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
678+ row := make([]renderer.Cell, 16)
679+
680+ drawStatusBarState(row, state, resolveThemeStyles(nil))
681+
682+ text := rowText(row)
683+ require.Equal(t, " cur ", text)
684+ require.NotContains(t, text, "main")
685+ require.NotContains(t, text, "copied")
686+}
687+
688+func TestStatusBarEmptyBottomRightScriptKeepsCopyFeedbackBehavior(t *testing.T) {
689+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
690+ row := make([]renderer.Cell, 12)
691+
692+ drawStatusBarState(row, state, resolveThemeStyles(nil))
693+
694+ require.Equal(t, " cur copied", rowText(row))
695+}
696+
697+func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) {
698+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
699+ row := make([]renderer.Cell, 24)
700+
701+ drawStatusBarState(row, state, resolveThemeStyles(nil))
702+
703+ text := rowText(row)
704+ require.Contains(t, text, " fresh")
705+ require.NotContains(t, text, "middle")
706+ require.True(t, strings.HasSuffix(text, " git"), text)
707+}
708+
647 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {709 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
648 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}710 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
649 row := make([]renderer.Cell, 20)711 row := make([]renderer.Cell, 20)
A scripts/vev-bar-bottom-right +50 -0
@@ -0,0 +1,50 @@
1+#!/bin/sh
2+
3+# Print compact git state for vev's bottom-right bar anchor.
4+# Always inspect VEV_PANE_CWD, never the daemon's current working directory.
5+
6+if [ -z "${VEV_PANE_CWD:-}" ]; then
7+ printf '\n'
8+ exit 0
9+fi
10+
11+if ! git -C "$VEV_PANE_CWD" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
12+ printf '\n'
13+ exit 0
14+fi
15+
16+name=$(git -C "$VEV_PANE_CWD" branch --show-current 2>/dev/null || true)
17+if [ -z "$name" ]; then
18+ name=$(git -C "$VEV_PANE_CWD" rev-parse --short HEAD 2>/dev/null || true)
19+fi
20+if [ -z "$name" ]; then
21+ printf '\n'
22+ exit 0
23+fi
24+
25+line=$name
26+
27+counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true)
28+if [ -n "$counts" ]; then
29+ set -- $counts
30+ behind=${1:-0}
31+ ahead=${2:-0}
32+ case $ahead in
33+ ''|*[!0-9]*) ahead=0 ;;
34+ esac
35+ case $behind in
36+ ''|*[!0-9]*) behind=0 ;;
37+ esac
38+ if [ "$ahead" -gt 0 ] 2>/dev/null; then
39+ line="$line ↑$ahead"
40+ fi
41+ if [ "$behind" -gt 0 ] 2>/dev/null; then
42+ line="$line ↓$behind"
43+ fi
44+fi
45+
46+if [ -n "$(git -C "$VEV_PANE_CWD" status --porcelain 2>/dev/null || true)" ]; then
47+ line="$line *"
48+fi
49+
50+printf '%s\n' "$line"
@@ -0,0 +1,50 @@
1+#!/bin/sh
2+
3+# Print compact git state for vev's bottom-right bar anchor.
4+# Always inspect VEV_PANE_CWD, never the daemon's current working directory.
5+
6+if [ -z "${VEV_PANE_CWD:-}" ]; then
7+ printf '\n'
8+ exit 0
9+fi
10+
11+if ! git -C "$VEV_PANE_CWD" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
12+ printf '\n'
13+ exit 0
14+fi
15+
16+name=$(git -C "$VEV_PANE_CWD" branch --show-current 2>/dev/null || true)
17+if [ -z "$name" ]; then
18+ name=$(git -C "$VEV_PANE_CWD" rev-parse --short HEAD 2>/dev/null || true)
19+fi
20+if [ -z "$name" ]; then
21+ printf '\n'
22+ exit 0
23+fi
24+
25+line=$name
26+
27+counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true)
28+if [ -n "$counts" ]; then
29+ set -- $counts
30+ behind=${1:-0}
31+ ahead=${2:-0}
32+ case $ahead in
33+ ''|*[!0-9]*) ahead=0 ;;
34+ esac
35+ case $behind in
36+ ''|*[!0-9]*) behind=0 ;;
37+ esac
38+ if [ "$ahead" -gt 0 ] 2>/dev/null; then
39+ line="$line ↑$ahead"
40+ fi
41+ if [ "$behind" -gt 0 ] 2>/dev/null; then
42+ line="$line ↓$behind"
43+ fi
44+fi
45+
46+if [ -n "$(git -C "$VEV_PANE_CWD" status --porcelain 2>/dev/null || true)" ]; then
47+ line="$line *"
48+fi
49+
50+printf '%s\n' "$line"
A scripts/vev-bar-top-right +52 -0
@@ -0,0 +1,52 @@
1+#!/bin/sh
2+
3+# Print a compact, single-line system summary for vev's top-right bar anchor.
4+# Segments that cannot be read are omitted without producing stderr.
5+
6+now=$(date +%H:%M 2>/dev/null || true)
7+
8+cpu_pct=
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+ )
21+fi
22+
23+mem_pct=
24+if [ -r /proc/meminfo ]; then
25+ mem_pct=$(
26+ awk '
27+ $1 == "MemTotal:" { total = $2 }
28+ $1 == "MemAvailable:" { available = $2 }
29+ END {
30+ if (total > 0 && available >= 0) printf "%d", ((total - available) * 100) / total
31+ }
32+ ' /proc/meminfo 2>/dev/null || true
33+ )
34+fi
35+
36+line=$now
37+if [ -n "$cpu_pct" ]; then
38+ if [ -n "$line" ]; then
39+ line="$line cpu $cpu_pct%"
40+ else
41+ line="cpu $cpu_pct%"
42+ fi
43+fi
44+if [ -n "$mem_pct" ]; then
45+ if [ -n "$line" ]; then
46+ line="$line mem $mem_pct%"
47+ else
48+ line="mem $mem_pct%"
49+ fi
50+fi
51+
52+printf '%s\n' "$line"
@@ -0,0 +1,52 @@
1+#!/bin/sh
2+
3+# Print a compact, single-line system summary for vev's top-right bar anchor.
4+# Segments that cannot be read are omitted without producing stderr.
5+
6+now=$(date +%H:%M 2>/dev/null || true)
7+
8+cpu_pct=
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+ )
21+fi
22+
23+mem_pct=
24+if [ -r /proc/meminfo ]; then
25+ mem_pct=$(
26+ awk '
27+ $1 == "MemTotal:" { total = $2 }
28+ $1 == "MemAvailable:" { available = $2 }
29+ END {
30+ if (total > 0 && available >= 0) printf "%d", ((total - available) * 100) / total
31+ }
32+ ' /proc/meminfo 2>/dev/null || true
33+ )
34+fi
35+
36+line=$now
37+if [ -n "$cpu_pct" ]; then
38+ if [ -n "$line" ]; then
39+ line="$line cpu $cpu_pct%"
40+ else
41+ line="cpu $cpu_pct%"
42+ fi
43+fi
44+if [ -n "$mem_pct" ]; then
45+ if [ -n "$line" ]; then
46+ line="$line mem $mem_pct%"
47+ else
48+ line="mem $mem_pct%"
49+ fi
50+fi
51+
52+printf '%s\n' "$line"

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 12

Tags 11

↑↓ moveenter openesc close