detached 7447d40

Merge pull request #30 from bnema/feat/bar-right-anchor-scripts

feat(daemon): add scriptable bar right anchors
commit
7447d409fe3e1e7fc7d7255f4f882035a50ec98d
author
bnema · · 2026-07-06 16:39 UTC
committer
GitHub · · 2026-07-06 16:39 UTC
merge of
14f582a + 89110a8 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
25 files · +1794 -15
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. 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+
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. 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+
101 ## Remote attach116 ## Remote attach
102 117
103 ```sh118 ```sh
M internal/adapters/config/config.go +53 -1
@@ -8,6 +8,7 @@ import (
88 "io"
99 "os"
1010 "regexp"
11+ "strconv"
1112 "strings"
1213 "time"
1314
@@ -60,6 +61,31 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6061 continue
6162 }
6263 cfg.Theme = mode
64+ case key == "bar.top-right":
65+ command, ok := parseBarCommand(value)
66+ if !ok {
67+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
68+ continue
69+ }
70+ cfg.Bar.TopRight = command
71+ case key == "bar.bottom-right":
72+ command, ok := parseBarCommand(value)
73+ if !ok {
74+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
75+ continue
76+ }
77+ cfg.Bar.BottomRight = command
78+ case key == "bar.interval":
79+ interval, err := time.ParseDuration(value)
80+ if err != nil {
81+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
82+ continue
83+ }
84+ if interval < domain.MinBarInterval {
85+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
86+ interval = domain.MinBarInterval
87+ }
88+ cfg.Bar.Interval = interval
6389 case strings.HasPrefix(key, "code."):
6490 codeKey := strings.TrimPrefix(key, "code.")
6591 if codeKey == "" {
@@ -198,6 +224,18 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
198224 return out, warnings
199225 }
200226
227+func parseBarCommand(value string) (string, bool) {
228+ value = strings.TrimSpace(value)
229+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
230+ unquoted, err := strconv.Unquote(value)
231+ if err != nil {
232+ return value, false
233+ }
234+ return unquoted, true
235+ }
236+ return value, true
237+}
238+
201239 func parseTheme(value string) (domain.ThemeMode, bool) {
202240 switch strings.ToLower(strings.TrimSpace(value)) {
203241 case "auto", "":
@@ -212,8 +250,22 @@ func parseTheme(value string) (domain.ThemeMode, bool) {
212250 }
213251
214252 func stripInlineComment(line string) string {
253+ inQuote := false
254+ escaped := false
215255 for i, r := range line {
216- if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
256+ if escaped {
257+ escaped = false
258+ continue
259+ }
260+ if inQuote && r == '\\' {
261+ escaped = true
262+ continue
263+ }
264+ if r == '"' {
265+ inQuote = !inQuote
266+ continue
267+ }
268+ if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
217269 return strings.TrimSpace(line[:i])
218270 }
219271 }
@@ -8,6 +8,7 @@ import (
8 "io"8 "io"
9 "os"9 "os"
10 "regexp"10 "regexp"
11+ "strconv"
11 "strings"12 "strings"
12 "time"13 "time"
13 14
@@ -60,6 +61,31 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
60 continue61 continue
61 }62 }
62 cfg.Theme = mode63 cfg.Theme = mode
64+ case key == "bar.top-right":
65+ command, ok := parseBarCommand(value)
66+ if !ok {
67+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
68+ continue
69+ }
70+ cfg.Bar.TopRight = command
71+ case key == "bar.bottom-right":
72+ command, ok := parseBarCommand(value)
73+ if !ok {
74+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
75+ continue
76+ }
77+ cfg.Bar.BottomRight = command
78+ case key == "bar.interval":
79+ interval, err := time.ParseDuration(value)
80+ if err != nil {
81+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
82+ continue
83+ }
84+ if interval < domain.MinBarInterval {
85+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
86+ interval = domain.MinBarInterval
87+ }
88+ cfg.Bar.Interval = interval
63 case strings.HasPrefix(key, "code."):89 case strings.HasPrefix(key, "code."):
64 codeKey := strings.TrimPrefix(key, "code.")90 codeKey := strings.TrimPrefix(key, "code.")
65 if codeKey == "" {91 if codeKey == "" {
@@ -198,6 +224,18 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
198 return out, warnings224 return out, warnings
199 }225 }
200 226
227+func parseBarCommand(value string) (string, bool) {
228+ value = strings.TrimSpace(value)
229+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
230+ unquoted, err := strconv.Unquote(value)
231+ if err != nil {
232+ return value, false
233+ }
234+ return unquoted, true
235+ }
236+ return value, true
237+}
238+
201 func parseTheme(value string) (domain.ThemeMode, bool) {239 func parseTheme(value string) (domain.ThemeMode, bool) {
202 switch strings.ToLower(strings.TrimSpace(value)) {240 switch strings.ToLower(strings.TrimSpace(value)) {
203 case "auto", "":241 case "auto", "":
@@ -212,8 +250,22 @@ func parseTheme(value string) (domain.ThemeMode, bool) {
212 }250 }
213 251
214 func stripInlineComment(line string) string {252 func stripInlineComment(line string) string {
253+ inQuote := false
254+ escaped := false
215 for i, r := range line {255 for i, r := range line {
216- if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {256+ if escaped {
257+ escaped = false
258+ continue
259+ }
260+ if inQuote && r == '\\' {
261+ escaped = true
262+ continue
263+ }
264+ if r == '"' {
265+ inQuote = !inQuote
266+ continue
267+ }
268+ if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
217 return strings.TrimSpace(line[:i])269 return strings.TrimSpace(line[:i])
218 }270 }
219 }271 }
M internal/adapters/config/config_test.go +104 -0
@@ -91,6 +91,107 @@ 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 quoted commands unquote",
110+ input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Bar: domain.BarConfig{
114+ TopRight: "date +%H:%M",
115+ BottomRight: "",
116+ Interval: 5 * time.Second,
117+ },
118+ BindingEntries: []domain.ConfigEntry{},
119+ Codes: map[string]string{},
120+ },
121+ },
122+ {
123+ name: "bar settings quoted commands preserve hash",
124+ input: "bar.top-right = \"echo foo #1\" # inline comment\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Bar: domain.BarConfig{
128+ TopRight: "echo foo #1",
129+ BottomRight: "vev-bar-bottom-right",
130+ Interval: 5 * time.Second,
131+ },
132+ BindingEntries: []domain.ConfigEntry{},
133+ Codes: map[string]string{},
134+ },
135+ },
136+ {
137+ name: "bar settings malformed quoted command warns and keeps default",
138+ input: "bar.top-right = \"bad\\q\"\n",
139+ want: domain.Config{
140+ Theme: domain.ThemeAuto,
141+ Bar: domain.BarConfig{
142+ TopRight: "vev-bar-top-right",
143+ BottomRight: "vev-bar-bottom-right",
144+ Interval: 5 * time.Second,
145+ },
146+ BindingEntries: []domain.ConfigEntry{},
147+ Codes: map[string]string{},
148+ },
149+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
150+ },
151+ {
152+ name: "bar settings empty commands disable anchors",
153+ input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
154+ want: domain.Config{
155+ Theme: domain.ThemeAuto,
156+ Bar: domain.BarConfig{
157+ TopRight: "",
158+ BottomRight: "",
159+ Interval: 5 * time.Second,
160+ },
161+ BindingEntries: []domain.ConfigEntry{},
162+ Codes: map[string]string{},
163+ },
164+ },
165+ {
166+ name: "bar interval invalid warns and keeps default",
167+ input: "bar.interval = nope\n",
168+ want: domain.Config{
169+ Theme: domain.ThemeAuto,
170+ Bar: domain.BarConfig{
171+ TopRight: "vev-bar-top-right",
172+ BottomRight: "vev-bar-bottom-right",
173+ Interval: 5 * time.Second,
174+ },
175+ BindingEntries: []domain.ConfigEntry{},
176+ Codes: map[string]string{},
177+ },
178+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
179+ },
180+ {
181+ name: "bar interval below minimum clamps and warns",
182+ input: "bar.interval = 100ms\n",
183+ want: domain.Config{
184+ Theme: domain.ThemeAuto,
185+ Bar: domain.BarConfig{
186+ TopRight: "vev-bar-top-right",
187+ BottomRight: "vev-bar-bottom-right",
188+ Interval: time.Second,
189+ },
190+ BindingEntries: []domain.ConfigEntry{},
191+ Codes: map[string]string{},
192+ },
193+ wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
194+ },
94195 {
95196 name: "snapshot restore processes",
96197 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
@@ -140,6 +241,9 @@ func TestParse(t *testing.T) {
140241 t.Run(tt.name, func(t *testing.T) {
141242 t.Parallel()
142243
244+ if tt.want.Bar.Interval == 0 {
245+ tt.want.Bar = domain.Defaults().Bar
246+ }
143247 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
144248 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
145249 }
@@ -91,6 +91,107 @@ 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 quoted commands unquote",
110+ input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Bar: domain.BarConfig{
114+ TopRight: "date +%H:%M",
115+ BottomRight: "",
116+ Interval: 5 * time.Second,
117+ },
118+ BindingEntries: []domain.ConfigEntry{},
119+ Codes: map[string]string{},
120+ },
121+ },
122+ {
123+ name: "bar settings quoted commands preserve hash",
124+ input: "bar.top-right = \"echo foo #1\" # inline comment\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Bar: domain.BarConfig{
128+ TopRight: "echo foo #1",
129+ BottomRight: "vev-bar-bottom-right",
130+ Interval: 5 * time.Second,
131+ },
132+ BindingEntries: []domain.ConfigEntry{},
133+ Codes: map[string]string{},
134+ },
135+ },
136+ {
137+ name: "bar settings malformed quoted command warns and keeps default",
138+ input: "bar.top-right = \"bad\\q\"\n",
139+ want: domain.Config{
140+ Theme: domain.ThemeAuto,
141+ Bar: domain.BarConfig{
142+ TopRight: "vev-bar-top-right",
143+ BottomRight: "vev-bar-bottom-right",
144+ Interval: 5 * time.Second,
145+ },
146+ BindingEntries: []domain.ConfigEntry{},
147+ Codes: map[string]string{},
148+ },
149+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
150+ },
151+ {
152+ name: "bar settings empty commands disable anchors",
153+ input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
154+ want: domain.Config{
155+ Theme: domain.ThemeAuto,
156+ Bar: domain.BarConfig{
157+ TopRight: "",
158+ BottomRight: "",
159+ Interval: 5 * time.Second,
160+ },
161+ BindingEntries: []domain.ConfigEntry{},
162+ Codes: map[string]string{},
163+ },
164+ },
165+ {
166+ name: "bar interval invalid warns and keeps default",
167+ input: "bar.interval = nope\n",
168+ want: domain.Config{
169+ Theme: domain.ThemeAuto,
170+ Bar: domain.BarConfig{
171+ TopRight: "vev-bar-top-right",
172+ BottomRight: "vev-bar-bottom-right",
173+ Interval: 5 * time.Second,
174+ },
175+ BindingEntries: []domain.ConfigEntry{},
176+ Codes: map[string]string{},
177+ },
178+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
179+ },
180+ {
181+ name: "bar interval below minimum clamps and warns",
182+ input: "bar.interval = 100ms\n",
183+ want: domain.Config{
184+ Theme: domain.ThemeAuto,
185+ Bar: domain.BarConfig{
186+ TopRight: "vev-bar-top-right",
187+ BottomRight: "vev-bar-bottom-right",
188+ Interval: time.Second,
189+ },
190+ BindingEntries: []domain.ConfigEntry{},
191+ Codes: map[string]string{},
192+ },
193+ wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
194+ },
94 {195 {
95 name: "snapshot restore processes",196 name: "snapshot restore processes",
96 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",197 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
@@ -140,6 +241,9 @@ func TestParse(t *testing.T) {
140 t.Run(tt.name, func(t *testing.T) {241 t.Run(tt.name, func(t *testing.T) {
141 t.Parallel()242 t.Parallel()
142 243
244+ if tt.want.Bar.Interval == 0 {
245+ tt.want.Bar = domain.Defaults().Bar
246+ }
143 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {247 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
144 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)248 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
145 }249 }
A internal/adapters/shellcmd/runner.go +63 -0
@@ -0,0 +1,63 @@
1+package shellcmd
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os/exec"
8+ "syscall"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// Runner executes bounded shell commands for bar scripts and similar usecases.
14+type Runner struct{}
15+
16+// New returns a production shell command runner.
17+func New() Runner { return Runner{} }
18+
19+// Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr.
20+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
21+ cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
22+ cmd.Env = spec.Env
23+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
24+ cmd.WaitDelay = spec.Timeout
25+ cmd.Cancel = func() error {
26+ if cmd.Process == nil {
27+ return nil
28+ }
29+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
30+ }
31+ var stdout boundedBuffer
32+ stdout.limit = spec.StdoutLimit
33+ cmd.Stdout = &stdout
34+ cmd.Stderr = io.Discard
35+ if err := cmd.Run(); err != nil {
36+ return stdout.Bytes(), err
37+ }
38+ return stdout.Bytes(), nil
39+}
40+
41+type boundedBuffer struct {
42+ buf bytes.Buffer
43+ limit int
44+}
45+
46+func (b *boundedBuffer) Write(p []byte) (int, error) {
47+ if b.limit <= 0 {
48+ return len(p), nil
49+ }
50+ remaining := b.limit - b.buf.Len()
51+ if remaining > 0 {
52+ if len(p) < remaining {
53+ remaining = len(p)
54+ }
55+ _, _ = b.buf.Write(p[:remaining])
56+ }
57+ return len(p), nil
58+}
59+
60+func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
61+
62+var _ ports.ShellCommandRunner = Runner{}
63+var _ io.Writer = (*boundedBuffer)(nil)
@@ -0,0 +1,63 @@
1+package shellcmd
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os/exec"
8+ "syscall"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// Runner executes bounded shell commands for bar scripts and similar usecases.
14+type Runner struct{}
15+
16+// New returns a production shell command runner.
17+func New() Runner { return Runner{} }
18+
19+// Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr.
20+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
21+ cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
22+ cmd.Env = spec.Env
23+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
24+ cmd.WaitDelay = spec.Timeout
25+ cmd.Cancel = func() error {
26+ if cmd.Process == nil {
27+ return nil
28+ }
29+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
30+ }
31+ var stdout boundedBuffer
32+ stdout.limit = spec.StdoutLimit
33+ cmd.Stdout = &stdout
34+ cmd.Stderr = io.Discard
35+ if err := cmd.Run(); err != nil {
36+ return stdout.Bytes(), err
37+ }
38+ return stdout.Bytes(), nil
39+}
40+
41+type boundedBuffer struct {
42+ buf bytes.Buffer
43+ limit int
44+}
45+
46+func (b *boundedBuffer) Write(p []byte) (int, error) {
47+ if b.limit <= 0 {
48+ return len(p), nil
49+ }
50+ remaining := b.limit - b.buf.Len()
51+ if remaining > 0 {
52+ if len(p) < remaining {
53+ remaining = len(p)
54+ }
55+ _, _ = b.buf.Write(p[:remaining])
56+ }
57+ return len(p), nil
58+}
59+
60+func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
61+
62+var _ ports.ShellCommandRunner = Runner{}
63+var _ io.Writer = (*boundedBuffer)(nil)
A internal/adapters/shellcmd/runner_test.go +107 -0
@@ -0,0 +1,107 @@
1+package shellcmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRunnerRun(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ command string
17+ limit int
18+ want string
19+ }{
20+ {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
21+ {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
22+ {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"},
23+ {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
24+ {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
29+ defer cancel()
30+
31+ got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit})
32+ if err != nil {
33+ t.Fatalf("Run() error = %v", err)
34+ }
35+ if string(got) != tt.want {
36+ t.Fatalf("Run() = %q, want %q", string(got), tt.want)
37+ }
38+ })
39+ }
40+}
41+
42+func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
43+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
44+ defer cancel()
45+
46+ got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3})
47+ if err == nil {
48+ t.Fatal("Run() error = nil, want non-zero exit error")
49+ }
50+ if string(got) != "abc" {
51+ t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc")
52+ }
53+}
54+
55+func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) {
56+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
57+ defer cancel()
58+ start := time.Now()
59+
60+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
61+
62+ if err == nil {
63+ t.Fatal("Run() error = nil, want timeout or wait error")
64+ }
65+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
66+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
67+ }
68+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
69+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
70+ }
71+}
72+
73+func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
74+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
75+ defer cancel()
76+ start := time.Now()
77+
78+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
79+
80+ if err == nil {
81+ t.Fatal("Run() error = nil, want timeout or wait error")
82+ }
83+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
84+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
85+ }
86+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
87+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
88+ }
89+}
90+
91+func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
92+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
93+ defer cancel()
94+
95+ got, err := New().Run(ctx, ports.CommandSpec{
96+ Command: "printf '%s' \"$VEV_TEST_VALUE\"",
97+ Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"},
98+ Timeout: time.Second,
99+ StdoutLimit: 1024,
100+ })
101+ if err != nil {
102+ t.Fatalf("Run() error = %v", err)
103+ }
104+ if strings.TrimSpace(string(got)) != "from-env" {
105+ t.Fatalf("Run() env output = %q, want from-env", string(got))
106+ }
107+}
@@ -0,0 +1,107 @@
1+package shellcmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRunnerRun(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ command string
17+ limit int
18+ want string
19+ }{
20+ {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
21+ {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
22+ {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"},
23+ {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
24+ {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
29+ defer cancel()
30+
31+ got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit})
32+ if err != nil {
33+ t.Fatalf("Run() error = %v", err)
34+ }
35+ if string(got) != tt.want {
36+ t.Fatalf("Run() = %q, want %q", string(got), tt.want)
37+ }
38+ })
39+ }
40+}
41+
42+func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
43+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
44+ defer cancel()
45+
46+ got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3})
47+ if err == nil {
48+ t.Fatal("Run() error = nil, want non-zero exit error")
49+ }
50+ if string(got) != "abc" {
51+ t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc")
52+ }
53+}
54+
55+func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) {
56+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
57+ defer cancel()
58+ start := time.Now()
59+
60+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
61+
62+ if err == nil {
63+ t.Fatal("Run() error = nil, want timeout or wait error")
64+ }
65+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
66+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
67+ }
68+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
69+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
70+ }
71+}
72+
73+func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
74+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
75+ defer cancel()
76+ start := time.Now()
77+
78+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
79+
80+ if err == nil {
81+ t.Fatal("Run() error = nil, want timeout or wait error")
82+ }
83+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
84+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
85+ }
86+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
87+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
88+ }
89+}
90+
91+func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
92+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
93+ defer cancel()
94+
95+ got, err := New().Run(ctx, ports.CommandSpec{
96+ Command: "printf '%s' \"$VEV_TEST_VALUE\"",
97+ Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"},
98+ Timeout: time.Second,
99+ StdoutLimit: 1024,
100+ })
101+ if err != nil {
102+ t.Fatalf("Run() error = %v", err)
103+ }
104+ if strings.TrimSpace(string(got)) != "from-env" {
105+ t.Fatalf("Run() env output = %q, want from-env", string(got))
106+ }
107+}
M internal/app/run.go +2 -0
@@ -28,6 +28,7 @@ import (
2828 "github.com/bnema/vev/internal/adapters/dgram"
2929 "github.com/bnema/vev/internal/adapters/ipc"
3030 "github.com/bnema/vev/internal/adapters/pty"
31+ "github.com/bnema/vev/internal/adapters/shellcmd"
3132 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
3233 "github.com/bnema/vev/internal/adapters/sshstdio"
3334 "github.com/bnema/vev/internal/adapters/term"
@@ -284,6 +285,7 @@ func runDaemon() error {
284285 }
285286 logConfigWarnings(log, warnings)
286287 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
288+ daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
287289 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
288290 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
289291 storePath := persist.StorePath(platform.StateDir())
@@ -28,6 +28,7 @@ import (
28 "github.com/bnema/vev/internal/adapters/dgram"28 "github.com/bnema/vev/internal/adapters/dgram"
29 "github.com/bnema/vev/internal/adapters/ipc"29 "github.com/bnema/vev/internal/adapters/ipc"
30 "github.com/bnema/vev/internal/adapters/pty"30 "github.com/bnema/vev/internal/adapters/pty"
31+ "github.com/bnema/vev/internal/adapters/shellcmd"
31 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"32 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
32 "github.com/bnema/vev/internal/adapters/sshstdio"33 "github.com/bnema/vev/internal/adapters/sshstdio"
33 "github.com/bnema/vev/internal/adapters/term"34 "github.com/bnema/vev/internal/adapters/term"
@@ -284,6 +285,7 @@ func runDaemon() error {
284 }285 }
285 logConfigWarnings(log, warnings)286 logConfigWarnings(log, warnings)
286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))287 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
288+ daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
287 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))289 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))290 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
289 storePath := persist.StorePath(platform.StateDir())291 storePath := persist.StorePath(platform.StateDir())
M internal/domain/config.go +19 -1
@@ -1,5 +1,10 @@
11 package domain
22
3+import "time"
4+
5+// MinBarInterval is the minimum supported bar refresh interval.
6+const MinBarInterval = time.Second
7+
38 // ThemeMode selects how vev derives its color scheme.
49 type ThemeMode int
510
@@ -35,11 +40,19 @@ func DefaultSnapshotRestoreProcesses() []string {
3540 return append([]string(nil), defaultSnapshotRestoreProcesses...)
3641 }
3742
43+// BarConfig contains user-configurable bar right-anchor settings.
44+type BarConfig struct {
45+ TopRight string
46+ BottomRight string
47+ Interval time.Duration
48+}
49+
3850 // Config is the user-editable vev configuration after parsing. Unknown binding
3951 // keys are preserved here (in BindingEntries, in file order) so the usecase
4052 // layer can decide which actions it understands.
4153 type Config struct {
4254 Theme ThemeMode
55+ Bar BarConfig
4356 BindingEntries []ConfigEntry
4457 Codes map[string]string
4558 Snapshot SnapshotConfig
@@ -55,7 +68,12 @@ type Warning struct {
5568 // Defaults returns vev's default configuration.
5669 func Defaults() Config {
5770 return Config{
58- Theme: ThemeAuto,
71+ Theme: ThemeAuto,
72+ Bar: BarConfig{
73+ TopRight: "vev-bar-top-right",
74+ BottomRight: "vev-bar-bottom-right",
75+ Interval: 5 * time.Second,
76+ },
5977 BindingEntries: []ConfigEntry{},
6078 Codes: map[string]string{},
6179 Snapshot: SnapshotConfig{
@@ -1,5 +1,10 @@
1 package domain1 package domain
2 2
3+import "time"
4+
5+// MinBarInterval is the minimum supported bar refresh interval.
6+const MinBarInterval = time.Second
7+
3 // ThemeMode selects how vev derives its color scheme.8 // ThemeMode selects how vev derives its color scheme.
4 type ThemeMode int9 type ThemeMode int
5 10
@@ -35,11 +40,19 @@ func DefaultSnapshotRestoreProcesses() []string {
35 return append([]string(nil), defaultSnapshotRestoreProcesses...)40 return append([]string(nil), defaultSnapshotRestoreProcesses...)
36 }41 }
37 42
43+// BarConfig contains user-configurable bar right-anchor settings.
44+type BarConfig struct {
45+ TopRight string
46+ BottomRight string
47+ Interval time.Duration
48+}
49+
38 // Config is the user-editable vev configuration after parsing. Unknown binding50 // Config is the user-editable vev configuration after parsing. Unknown binding
39 // keys are preserved here (in BindingEntries, in file order) so the usecase51 // keys are preserved here (in BindingEntries, in file order) so the usecase
40 // layer can decide which actions it understands.52 // layer can decide which actions it understands.
41 type Config struct {53 type Config struct {
42 Theme ThemeMode54 Theme ThemeMode
55+ Bar BarConfig
43 BindingEntries []ConfigEntry56 BindingEntries []ConfigEntry
44 Codes map[string]string57 Codes map[string]string
45 Snapshot SnapshotConfig58 Snapshot SnapshotConfig
@@ -55,7 +68,12 @@ type Warning struct {
55 // Defaults returns vev's default configuration.68 // Defaults returns vev's default configuration.
56 func Defaults() Config {69 func Defaults() Config {
57 return Config{70 return Config{
58- Theme: ThemeAuto,71+ Theme: ThemeAuto,
72+ Bar: BarConfig{
73+ TopRight: "vev-bar-top-right",
74+ BottomRight: "vev-bar-bottom-right",
75+ Interval: 5 * time.Second,
76+ },
59 BindingEntries: []ConfigEntry{},77 BindingEntries: []ConfigEntry{},
60 Codes: map[string]string{},78 Codes: map[string]string{},
61 Snapshot: SnapshotConfig{79 Snapshot: SnapshotConfig{
M internal/ports/mocks/mocks.go +95 -0
@@ -2068,6 +2068,101 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
20682068 return _c
20692069 }
20702070
2071+// NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072+// The first argument is typically a *testing.T value.
2073+func NewMockShellCommandRunner(t interface {
2074+ mock.TestingT
2075+ Cleanup(func())
2076+}) *MockShellCommandRunner {
2077+ mock := &MockShellCommandRunner{}
2078+ mock.Mock.Test(t)
2079+
2080+ t.Cleanup(func() { mock.AssertExpectations(t) })
2081+
2082+ return mock
2083+}
2084+
2085+// MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type
2086+type MockShellCommandRunner struct {
2087+ mock.Mock
2088+}
2089+
2090+type MockShellCommandRunner_Expecter struct {
2091+ mock *mock.Mock
2092+}
2093+
2094+func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
2095+ return &MockShellCommandRunner_Expecter{mock: &_m.Mock}
2096+}
2097+
2098+// Run provides a mock function for the type MockShellCommandRunner
2099+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
2100+ ret := _mock.Called(ctx, spec)
2101+
2102+ if len(ret) == 0 {
2103+ panic("no return value specified for Run")
2104+ }
2105+
2106+ var r0 []byte
2107+ var r1 error
2108+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {
2109+ return returnFunc(ctx, spec)
2110+ }
2111+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {
2112+ r0 = returnFunc(ctx, spec)
2113+ } else {
2114+ if ret.Get(0) != nil {
2115+ r0 = ret.Get(0).([]byte)
2116+ }
2117+ }
2118+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
2119+ r1 = returnFunc(ctx, spec)
2120+ } else {
2121+ r1 = ret.Error(1)
2122+ }
2123+ return r0, r1
2124+}
2125+
2126+// MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
2127+type MockShellCommandRunner_Run_Call struct {
2128+ *mock.Call
2129+}
2130+
2131+// Run is a helper method to define mock.On call
2132+// - ctx context.Context
2133+// - spec ports.CommandSpec
2134+func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call {
2135+ return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)}
2136+}
2137+
2138+func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call {
2139+ _c.Call.Run(func(args mock.Arguments) {
2140+ var arg0 context.Context
2141+ if args[0] != nil {
2142+ arg0 = args[0].(context.Context)
2143+ }
2144+ var arg1 ports.CommandSpec
2145+ if args[1] != nil {
2146+ arg1 = args[1].(ports.CommandSpec)
2147+ }
2148+ run(
2149+ arg0,
2150+ arg1,
2151+ )
2152+ })
2153+ return _c
2154+}
2155+
2156+func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {
2157+ _c.Call.Return(bytes, err)
2158+ return _c
2159+}
2160+
2161+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {
2162+ _c.Call.Return(run)
2163+ return _c
2164+}
2165+
20712166 // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
20722167 // The first argument is typically a *testing.T value.
20732168 func NewMockProcessInspector(t interface {
@@ -2068,6 +2068,101 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
2068 return _c2068 return _c
2069 }2069 }
2070 2070
2071+// NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072+// The first argument is typically a *testing.T value.
2073+func NewMockShellCommandRunner(t interface {
2074+ mock.TestingT
2075+ Cleanup(func())
2076+}) *MockShellCommandRunner {
2077+ mock := &MockShellCommandRunner{}
2078+ mock.Mock.Test(t)
2079+
2080+ t.Cleanup(func() { mock.AssertExpectations(t) })
2081+
2082+ return mock
2083+}
2084+
2085+// MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type
2086+type MockShellCommandRunner struct {
2087+ mock.Mock
2088+}
2089+
2090+type MockShellCommandRunner_Expecter struct {
2091+ mock *mock.Mock
2092+}
2093+
2094+func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
2095+ return &MockShellCommandRunner_Expecter{mock: &_m.Mock}
2096+}
2097+
2098+// Run provides a mock function for the type MockShellCommandRunner
2099+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
2100+ ret := _mock.Called(ctx, spec)
2101+
2102+ if len(ret) == 0 {
2103+ panic("no return value specified for Run")
2104+ }
2105+
2106+ var r0 []byte
2107+ var r1 error
2108+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {
2109+ return returnFunc(ctx, spec)
2110+ }
2111+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {
2112+ r0 = returnFunc(ctx, spec)
2113+ } else {
2114+ if ret.Get(0) != nil {
2115+ r0 = ret.Get(0).([]byte)
2116+ }
2117+ }
2118+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
2119+ r1 = returnFunc(ctx, spec)
2120+ } else {
2121+ r1 = ret.Error(1)
2122+ }
2123+ return r0, r1
2124+}
2125+
2126+// MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
2127+type MockShellCommandRunner_Run_Call struct {
2128+ *mock.Call
2129+}
2130+
2131+// Run is a helper method to define mock.On call
2132+// - ctx context.Context
2133+// - spec ports.CommandSpec
2134+func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call {
2135+ return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)}
2136+}
2137+
2138+func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call {
2139+ _c.Call.Run(func(args mock.Arguments) {
2140+ var arg0 context.Context
2141+ if args[0] != nil {
2142+ arg0 = args[0].(context.Context)
2143+ }
2144+ var arg1 ports.CommandSpec
2145+ if args[1] != nil {
2146+ arg1 = args[1].(ports.CommandSpec)
2147+ }
2148+ run(
2149+ arg0,
2150+ arg1,
2151+ )
2152+ })
2153+ return _c
2154+}
2155+
2156+func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {
2157+ _c.Call.Return(bytes, err)
2158+ return _c
2159+}
2160+
2161+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {
2162+ _c.Call.Return(run)
2163+ return _c
2164+}
2165+
2071 // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.2166 // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072 // The first argument is typically a *testing.T value.2167 // The first argument is typically a *testing.T value.
2073 func NewMockProcessInspector(t interface {2168 func NewMockProcessInspector(t interface {
M internal/ports/ports.go +13 -0
@@ -96,6 +96,19 @@ type Timer interface {
9696 Stop() bool
9797 }
9898
99+// CommandSpec describes a bounded shell command execution request.
100+type CommandSpec struct {
101+ Command string
102+ Env []string
103+ Timeout time.Duration
104+ StdoutLimit int
105+}
106+
107+// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
108+type ShellCommandRunner interface {
109+ Run(ctx context.Context, spec CommandSpec) ([]byte, error)
110+}
111+
99112 // ProcessInspector reads process metadata for daemon snapshot/persistence use.
100113 // Implementations may live in platform packages; usecases depend on this port.
101114 type ProcessInspector interface {
@@ -96,6 +96,19 @@ type Timer interface {
96 Stop() bool96 Stop() bool
97 }97 }
98 98
99+// CommandSpec describes a bounded shell command execution request.
100+type CommandSpec struct {
101+ Command string
102+ Env []string
103+ Timeout time.Duration
104+ StdoutLimit int
105+}
106+
107+// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
108+type ShellCommandRunner interface {
109+ Run(ctx context.Context, spec CommandSpec) ([]byte, error)
110+}
111+
99 // ProcessInspector reads process metadata for daemon snapshot/persistence use.112 // ProcessInspector reads process metadata for daemon snapshot/persistence use.
100 // Implementations may live in platform packages; usecases depend on this port.113 // Implementations may live in platform packages; usecases depend on this port.
101 type ProcessInspector interface {114 type ProcessInspector interface {
A internal/usecase/daemon/bar_refresh.go +317 -0
@@ -0,0 +1,317 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+type barScriptExecutor interface {
12+ run(context.Context, string, barScriptContext) (string, error)
13+}
14+
15+type barScriptConfig struct {
16+ topRight string
17+ bottomRight string
18+ interval time.Duration
19+}
20+
21+type barScriptOutputs struct {
22+ topRight string
23+ bottomRight string
24+}
25+
26+type barScriptState struct {
27+ mu sync.Mutex
28+ cfg barScriptConfig
29+ runner barScriptExecutor
30+ outputs map[domain.SessionID]barScriptOutputs
31+ lastRefresh map[domain.SessionID]time.Time
32+ lastContext map[domain.SessionID]barScriptContext
33+ running map[domain.SessionID]bool
34+ pending map[domain.SessionID]bool
35+ version uint64
36+}
37+
38+func effectiveBarInterval(d time.Duration) time.Duration {
39+ if d <= 0 {
40+ return 5 * time.Second
41+ }
42+ if d < domain.MinBarInterval {
43+ return domain.MinBarInterval
44+ }
45+ return d
46+}
47+
48+func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig {
49+ return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)}
50+}
51+
52+func (s *barScriptState) initLocked() {
53+ if s.outputs == nil {
54+ s.outputs = make(map[domain.SessionID]barScriptOutputs)
55+ }
56+ if s.lastRefresh == nil {
57+ s.lastRefresh = make(map[domain.SessionID]time.Time)
58+ }
59+ if s.lastContext == nil {
60+ s.lastContext = make(map[domain.SessionID]barScriptContext)
61+ }
62+ if s.running == nil {
63+ s.running = make(map[domain.SessionID]bool)
64+ }
65+ if s.pending == nil {
66+ s.pending = make(map[domain.SessionID]bool)
67+ }
68+}
69+
70+func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
71+ if d == nil || d.barScripts == nil || sess == nil {
72+ return "", ""
73+ }
74+ d.barScripts.mu.Lock()
75+ defer d.barScripts.mu.Unlock()
76+ out := d.barScripts.outputs[sess.id]
77+ return out.topRight, out.bottomRight
78+}
79+
80+func (d *Daemon) barScriptInterval() time.Duration {
81+ if d == nil || d.barScripts == nil {
82+ return 5 * time.Second
83+ }
84+ d.barScripts.mu.Lock()
85+ defer d.barScripts.mu.Unlock()
86+ return effectiveBarInterval(d.barScripts.cfg.interval)
87+}
88+
89+func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) {
90+ ctx := barScriptContext{Anchor: anchor}
91+ if sess == nil {
92+ return ctx, false
93+ }
94+ sess.mu.Lock()
95+ ctx.Session = sess.name
96+ active := sess.active
97+ var tb *tab
98+ if active >= 0 && active < len(sess.tabs) {
99+ tb = sess.tabs[active]
100+ }
101+ ac := sess.client
102+ ctx.PaneCWD = sess.cwd
103+ sess.mu.Unlock()
104+ if ac == nil || tb == nil {
105+ return ctx, false
106+ }
107+ tb.mu.Lock()
108+ ctx.Tab = tb.stableID
109+ ctx.Cols = tb.size.Cols
110+ pid := 0
111+ if p := tb.focusedPane(); p != nil {
112+ ctx.Pane = p.stableID
113+ if d != nil && d.procCwd != nil && p.pty != nil {
114+ pid = p.pty.Pid()
115+ }
116+ }
117+ tb.mu.Unlock()
118+ if pid != 0 && d != nil && d.procCwd != nil {
119+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
120+ ctx.PaneCWD = cwd
121+ }
122+ }
123+ return ctx, true
124+}
125+
126+func (d *Daemon) barScriptPoller(ctx context.Context) {
127+ timer := d.clock.NewTimer(d.barScriptInterval())
128+ defer timer.Stop()
129+ for {
130+ select {
131+ case <-ctx.Done():
132+ return
133+ case now := <-timer.C():
134+ for _, sess := range d.sessionsSnapshot() {
135+ d.refreshBarScriptsIfDue(sess, now, false)
136+ }
137+ timer.Reset(d.barScriptInterval())
138+ }
139+ }
140+}
141+
142+func (d *Daemon) sessionsSnapshot() []*session {
143+ d.mu.Lock()
144+ defer d.mu.Unlock()
145+ sessions := make([]*session, 0, len(d.sessions))
146+ for _, sess := range d.sessions {
147+ sessions = append(sessions, sess)
148+ }
149+ return sessions
150+}
151+
152+func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
153+ if d == nil || d.barScripts == nil {
154+ return
155+ }
156+ d.barScripts.mu.Lock()
157+ defer d.barScripts.mu.Unlock()
158+ delete(d.barScripts.outputs, id)
159+ delete(d.barScripts.lastRefresh, id)
160+ delete(d.barScripts.lastContext, id)
161+ delete(d.barScripts.running, id)
162+ delete(d.barScripts.pending, id)
163+}
164+
165+func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
166+ if d == nil || d.barScripts == nil || sess == nil {
167+ return false
168+ }
169+ baseCtx, ok := d.collectBarScriptContext(sess, "")
170+ if !ok {
171+ return false
172+ }
173+ d.barScripts.mu.Lock()
174+ d.barScripts.initLocked()
175+ if d.barScripts.lastContext[sess.id] != baseCtx {
176+ force = true
177+ }
178+ lastRefresh := d.barScripts.lastRefresh[sess.id]
179+ if d.barScripts.running[sess.id] {
180+ if force {
181+ d.barScripts.pending[sess.id] = true
182+ }
183+ d.barScripts.mu.Unlock()
184+ return false
185+ }
186+ if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
187+ if force {
188+ d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
189+ }
190+ d.barScripts.mu.Unlock()
191+ return false
192+ }
193+ if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
194+ d.barScripts.mu.Unlock()
195+ return false
196+ }
197+ cfg := d.barScripts.cfg
198+ if cfg.topRight == "" && cfg.bottomRight == "" {
199+ d.barScripts.lastRefresh[sess.id] = now
200+ d.barScripts.lastContext[sess.id] = baseCtx
201+ current := d.barScripts.outputs[sess.id]
202+ changed := current.topRight != "" || current.bottomRight != ""
203+ delete(d.barScripts.outputs, sess.id)
204+ d.barScripts.mu.Unlock()
205+ if changed {
206+ d.pokeSessionRender(sess)
207+ }
208+ return false
209+ }
210+ d.barScripts.running[sess.id] = true
211+ d.barScripts.lastRefresh[sess.id] = now
212+ d.barScripts.lastContext[sess.id] = baseCtx
213+ runner := d.barScripts.runner
214+ version := d.barScripts.version
215+ d.barScripts.mu.Unlock()
216+ go d.runBarScripts(sess, runner, cfg, baseCtx, version)
217+ return true
218+}
219+
220+func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
221+ if d.barScripts.pending[sess.id] {
222+ return
223+ }
224+ if d.clock == nil {
225+ return
226+ }
227+ if delay < 0 {
228+ delay = 0
229+ }
230+ d.barScripts.pending[sess.id] = true
231+ go func() {
232+ ctx := sess.ctx
233+ if ctx == nil {
234+ ctx = context.Background()
235+ }
236+ timer := d.clock.NewTimer(delay)
237+ select {
238+ case <-ctx.Done():
239+ timer.Stop()
240+ return
241+ case <-timer.C():
242+ }
243+ d.barScripts.mu.Lock()
244+ delete(d.barScripts.pending, sess.id)
245+ d.barScripts.mu.Unlock()
246+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
247+ }()
248+}
249+
250+func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {
251+ if runner == nil {
252+ runner = barScriptRunner{baseEnv: d.baseEnv}
253+ }
254+ ctx := sess.ctx
255+ if ctx == nil {
256+ ctx = context.Background()
257+ }
258+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name)
259+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name)
260+ d.barScripts.mu.Lock()
261+ d.barScripts.initLocked()
262+ if !d.barScripts.running[sess.id] || d.barScripts.version != version {
263+ delete(d.barScripts.running, sess.id)
264+ d.barScripts.mu.Unlock()
265+ return
266+ }
267+ current := d.barScripts.outputs[sess.id]
268+ changed := false
269+ if topOK && current.topRight != top {
270+ current.topRight = top
271+ changed = true
272+ }
273+ if bottomOK && current.bottomRight != bottom {
274+ current.bottomRight = bottom
275+ changed = true
276+ }
277+ d.barScripts.outputs[sess.id] = current
278+ pending := d.barScripts.pending[sess.id]
279+ delete(d.barScripts.running, sess.id)
280+ delete(d.barScripts.pending, sess.id)
281+ d.barScripts.mu.Unlock()
282+ if changed {
283+ d.pokeSessionRender(sess)
284+ }
285+ if pending {
286+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
287+ }
288+}
289+
290+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
291+ if command == "" {
292+ return "", true
293+ }
294+ base.Anchor = anchor
295+ out, err := runner.run(ctx, command, base)
296+ if err != nil {
297+ if d.log != nil {
298+ d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
299+ }
300+ return "", false
301+ }
302+ return out, true
303+}
304+
305+func (d *Daemon) pokeSessionRender(sess *session) {
306+ if sess == nil {
307+ return
308+ }
309+ if tb := sess.activeTab(); tb != nil {
310+ tb.mu.Lock()
311+ p := tb.focusedPane()
312+ tb.mu.Unlock()
313+ if p != nil {
314+ signal(p.dirty)
315+ }
316+ }
317+}
@@ -0,0 +1,317 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+type barScriptExecutor interface {
12+ run(context.Context, string, barScriptContext) (string, error)
13+}
14+
15+type barScriptConfig struct {
16+ topRight string
17+ bottomRight string
18+ interval time.Duration
19+}
20+
21+type barScriptOutputs struct {
22+ topRight string
23+ bottomRight string
24+}
25+
26+type barScriptState struct {
27+ mu sync.Mutex
28+ cfg barScriptConfig
29+ runner barScriptExecutor
30+ outputs map[domain.SessionID]barScriptOutputs
31+ lastRefresh map[domain.SessionID]time.Time
32+ lastContext map[domain.SessionID]barScriptContext
33+ running map[domain.SessionID]bool
34+ pending map[domain.SessionID]bool
35+ version uint64
36+}
37+
38+func effectiveBarInterval(d time.Duration) time.Duration {
39+ if d <= 0 {
40+ return 5 * time.Second
41+ }
42+ if d < domain.MinBarInterval {
43+ return domain.MinBarInterval
44+ }
45+ return d
46+}
47+
48+func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig {
49+ return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)}
50+}
51+
52+func (s *barScriptState) initLocked() {
53+ if s.outputs == nil {
54+ s.outputs = make(map[domain.SessionID]barScriptOutputs)
55+ }
56+ if s.lastRefresh == nil {
57+ s.lastRefresh = make(map[domain.SessionID]time.Time)
58+ }
59+ if s.lastContext == nil {
60+ s.lastContext = make(map[domain.SessionID]barScriptContext)
61+ }
62+ if s.running == nil {
63+ s.running = make(map[domain.SessionID]bool)
64+ }
65+ if s.pending == nil {
66+ s.pending = make(map[domain.SessionID]bool)
67+ }
68+}
69+
70+func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
71+ if d == nil || d.barScripts == nil || sess == nil {
72+ return "", ""
73+ }
74+ d.barScripts.mu.Lock()
75+ defer d.barScripts.mu.Unlock()
76+ out := d.barScripts.outputs[sess.id]
77+ return out.topRight, out.bottomRight
78+}
79+
80+func (d *Daemon) barScriptInterval() time.Duration {
81+ if d == nil || d.barScripts == nil {
82+ return 5 * time.Second
83+ }
84+ d.barScripts.mu.Lock()
85+ defer d.barScripts.mu.Unlock()
86+ return effectiveBarInterval(d.barScripts.cfg.interval)
87+}
88+
89+func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) {
90+ ctx := barScriptContext{Anchor: anchor}
91+ if sess == nil {
92+ return ctx, false
93+ }
94+ sess.mu.Lock()
95+ ctx.Session = sess.name
96+ active := sess.active
97+ var tb *tab
98+ if active >= 0 && active < len(sess.tabs) {
99+ tb = sess.tabs[active]
100+ }
101+ ac := sess.client
102+ ctx.PaneCWD = sess.cwd
103+ sess.mu.Unlock()
104+ if ac == nil || tb == nil {
105+ return ctx, false
106+ }
107+ tb.mu.Lock()
108+ ctx.Tab = tb.stableID
109+ ctx.Cols = tb.size.Cols
110+ pid := 0
111+ if p := tb.focusedPane(); p != nil {
112+ ctx.Pane = p.stableID
113+ if d != nil && d.procCwd != nil && p.pty != nil {
114+ pid = p.pty.Pid()
115+ }
116+ }
117+ tb.mu.Unlock()
118+ if pid != 0 && d != nil && d.procCwd != nil {
119+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
120+ ctx.PaneCWD = cwd
121+ }
122+ }
123+ return ctx, true
124+}
125+
126+func (d *Daemon) barScriptPoller(ctx context.Context) {
127+ timer := d.clock.NewTimer(d.barScriptInterval())
128+ defer timer.Stop()
129+ for {
130+ select {
131+ case <-ctx.Done():
132+ return
133+ case now := <-timer.C():
134+ for _, sess := range d.sessionsSnapshot() {
135+ d.refreshBarScriptsIfDue(sess, now, false)
136+ }
137+ timer.Reset(d.barScriptInterval())
138+ }
139+ }
140+}
141+
142+func (d *Daemon) sessionsSnapshot() []*session {
143+ d.mu.Lock()
144+ defer d.mu.Unlock()
145+ sessions := make([]*session, 0, len(d.sessions))
146+ for _, sess := range d.sessions {
147+ sessions = append(sessions, sess)
148+ }
149+ return sessions
150+}
151+
152+func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
153+ if d == nil || d.barScripts == nil {
154+ return
155+ }
156+ d.barScripts.mu.Lock()
157+ defer d.barScripts.mu.Unlock()
158+ delete(d.barScripts.outputs, id)
159+ delete(d.barScripts.lastRefresh, id)
160+ delete(d.barScripts.lastContext, id)
161+ delete(d.barScripts.running, id)
162+ delete(d.barScripts.pending, id)
163+}
164+
165+func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
166+ if d == nil || d.barScripts == nil || sess == nil {
167+ return false
168+ }
169+ baseCtx, ok := d.collectBarScriptContext(sess, "")
170+ if !ok {
171+ return false
172+ }
173+ d.barScripts.mu.Lock()
174+ d.barScripts.initLocked()
175+ if d.barScripts.lastContext[sess.id] != baseCtx {
176+ force = true
177+ }
178+ lastRefresh := d.barScripts.lastRefresh[sess.id]
179+ if d.barScripts.running[sess.id] {
180+ if force {
181+ d.barScripts.pending[sess.id] = true
182+ }
183+ d.barScripts.mu.Unlock()
184+ return false
185+ }
186+ if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
187+ if force {
188+ d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
189+ }
190+ d.barScripts.mu.Unlock()
191+ return false
192+ }
193+ if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
194+ d.barScripts.mu.Unlock()
195+ return false
196+ }
197+ cfg := d.barScripts.cfg
198+ if cfg.topRight == "" && cfg.bottomRight == "" {
199+ d.barScripts.lastRefresh[sess.id] = now
200+ d.barScripts.lastContext[sess.id] = baseCtx
201+ current := d.barScripts.outputs[sess.id]
202+ changed := current.topRight != "" || current.bottomRight != ""
203+ delete(d.barScripts.outputs, sess.id)
204+ d.barScripts.mu.Unlock()
205+ if changed {
206+ d.pokeSessionRender(sess)
207+ }
208+ return false
209+ }
210+ d.barScripts.running[sess.id] = true
211+ d.barScripts.lastRefresh[sess.id] = now
212+ d.barScripts.lastContext[sess.id] = baseCtx
213+ runner := d.barScripts.runner
214+ version := d.barScripts.version
215+ d.barScripts.mu.Unlock()
216+ go d.runBarScripts(sess, runner, cfg, baseCtx, version)
217+ return true
218+}
219+
220+func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
221+ if d.barScripts.pending[sess.id] {
222+ return
223+ }
224+ if d.clock == nil {
225+ return
226+ }
227+ if delay < 0 {
228+ delay = 0
229+ }
230+ d.barScripts.pending[sess.id] = true
231+ go func() {
232+ ctx := sess.ctx
233+ if ctx == nil {
234+ ctx = context.Background()
235+ }
236+ timer := d.clock.NewTimer(delay)
237+ select {
238+ case <-ctx.Done():
239+ timer.Stop()
240+ return
241+ case <-timer.C():
242+ }
243+ d.barScripts.mu.Lock()
244+ delete(d.barScripts.pending, sess.id)
245+ d.barScripts.mu.Unlock()
246+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
247+ }()
248+}
249+
250+func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {
251+ if runner == nil {
252+ runner = barScriptRunner{baseEnv: d.baseEnv}
253+ }
254+ ctx := sess.ctx
255+ if ctx == nil {
256+ ctx = context.Background()
257+ }
258+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name)
259+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name)
260+ d.barScripts.mu.Lock()
261+ d.barScripts.initLocked()
262+ if !d.barScripts.running[sess.id] || d.barScripts.version != version {
263+ delete(d.barScripts.running, sess.id)
264+ d.barScripts.mu.Unlock()
265+ return
266+ }
267+ current := d.barScripts.outputs[sess.id]
268+ changed := false
269+ if topOK && current.topRight != top {
270+ current.topRight = top
271+ changed = true
272+ }
273+ if bottomOK && current.bottomRight != bottom {
274+ current.bottomRight = bottom
275+ changed = true
276+ }
277+ d.barScripts.outputs[sess.id] = current
278+ pending := d.barScripts.pending[sess.id]
279+ delete(d.barScripts.running, sess.id)
280+ delete(d.barScripts.pending, sess.id)
281+ d.barScripts.mu.Unlock()
282+ if changed {
283+ d.pokeSessionRender(sess)
284+ }
285+ if pending {
286+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
287+ }
288+}
289+
290+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
291+ if command == "" {
292+ return "", true
293+ }
294+ base.Anchor = anchor
295+ out, err := runner.run(ctx, command, base)
296+ if err != nil {
297+ if d.log != nil {
298+ d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
299+ }
300+ return "", false
301+ }
302+ return out, true
303+}
304+
305+func (d *Daemon) pokeSessionRender(sess *session) {
306+ if sess == nil {
307+ return
308+ }
309+ if tb := sess.activeTab(); tb != nil {
310+ tb.mu.Lock()
311+ p := tb.focusedPane()
312+ tb.mu.Unlock()
313+ if p != nil {
314+ signal(p.dirty)
315+ }
316+ }
317+}
A internal/usecase/daemon/bar_refresh_test.go +312 -0
@@ -0,0 +1,312 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "log/slog"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/layout"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+type fakeBarRunner struct {
20+ mu sync.Mutex
21+ calls []barScriptContext
22+ outs []string
23+ errs []error
24+}
25+
26+func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) {
27+ r.mu.Lock()
28+ defer r.mu.Unlock()
29+ r.calls = append(r.calls, ctx)
30+ i := len(r.calls) - 1
31+ if i < len(r.errs) && r.errs[i] != nil {
32+ return "", r.errs[i]
33+ }
34+ if i < len(r.outs) {
35+ return r.outs[i], nil
36+ }
37+ return "", nil
38+}
39+
40+func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
41+ r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
42+ d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
43+ sess := newBarRefreshTestSession()
44+ sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}
45+
46+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
47+ waitBarRefreshIdle(t, d)
48+ require.Equal(t, 2, len(r.calls))
49+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
50+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick")
51+
52+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
53+ waitBarRefreshIdle(t, d)
54+ require.Equal(t, 4, len(r.calls))
55+ require.Equal(t, "top2", d.barStateFor(sess, "").topRight)
56+
57+ r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")}
58+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false))
59+ waitBarRefreshIdle(t, d)
60+ state := d.barStateFor(sess, "")
61+ require.Equal(t, "top2", state.topRight)
62+ require.Equal(t, "bot2", state.bottomRight)
63+}
64+
65+func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
66+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
67+ d := newBarRefreshTestDaemon(r, time.Second)
68+ sess := newBarRefreshTestSession()
69+ sess.name = "work"
70+ sess.cwd = "/repo"
71+ sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}}
72+ sess.active = 1
73+ sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
74+ active := sess.tabs[1].focusedPane()
75+ active.stableID = "pane-active"
76+ active.pty = newScriptPTY(nil)
77+ d.procCwd = func(pid int) (string, error) {
78+ require.Equal(t, 4242, pid)
79+ return "/pane-repo", nil
80+ }
81+
82+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
83+ waitBarRefreshIdle(t, d)
84+ require.Len(t, r.calls, 2)
85+ for _, call := range r.calls {
86+ require.Equal(t, "work", call.Session)
87+ require.Equal(t, "tab-active", call.Tab)
88+ require.Equal(t, "pane-active", call.Pane)
89+ require.Equal(t, "/pane-repo", call.PaneCWD)
90+ require.Equal(t, 132, call.Cols)
91+ }
92+ require.Equal(t, "top-right", r.calls[0].Anchor)
93+ require.Equal(t, "bottom-right", r.calls[1].Anchor)
94+}
95+
96+func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
97+ r := &fakeBarRunner{}
98+ d := newBarRefreshTestDaemon(r, time.Second)
99+ sess := newBarRefreshTestSession()
100+
101+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
102+ require.Empty(t, r.calls)
103+}
104+
105+func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
106+ r := &fakeBarRunner{}
107+ d := newBarRefreshTestDaemon(r, time.Second)
108+ d.barScripts.cfg.topRight = ""
109+ d.barScripts.cfg.bottomRight = ""
110+ sess := newBarRefreshTestSession()
111+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
112+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
113+
114+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
115+ require.Empty(t, r.calls)
116+ state := d.barStateFor(sess, "")
117+ require.Empty(t, state.topRight)
118+ require.Empty(t, state.bottomRight)
119+}
120+
121+func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
122+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
123+ d := newBarRefreshTestDaemon(r, 5*time.Second)
124+ sess := newBarRefreshTestSession()
125+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
126+
127+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
128+ waitBarRefreshIdle(t, d)
129+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
130+ require.Len(t, r.calls, 2)
131+ require.Eventually(t, func() bool {
132+ r.mu.Lock()
133+ defer r.mu.Unlock()
134+ return len(r.calls) == 4
135+ }, 1500*time.Millisecond, 10*time.Millisecond)
136+}
137+
138+func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
139+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
140+ d := newBarRefreshTestDaemon(r, 5*time.Second)
141+ sess := newBarRefreshTestSession()
142+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
143+
144+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
145+ waitBarRefreshIdle(t, d)
146+ sess.cwd = "/other"
147+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false))
148+ require.Eventually(t, func() bool {
149+ r.mu.Lock()
150+ defer r.mu.Unlock()
151+ return len(r.calls) == 4
152+ }, 1500*time.Millisecond, 10*time.Millisecond)
153+ require.Equal(t, "/other", r.calls[2].PaneCWD)
154+}
155+
156+func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
157+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
158+ d := newBarRefreshTestDaemon(r, 5*time.Second)
159+ sess := newBarRefreshTestSession()
160+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
161+
162+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
163+ waitBarRefreshIdle(t, d)
164+ sess.cwd = "/other"
165+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
166+ waitBarRefreshIdle(t, d)
167+ require.Len(t, r.calls, 4)
168+ require.Equal(t, "/other", r.calls[2].PaneCWD)
169+}
170+
171+func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
172+ r := &fakeBarRunner{}
173+ d := newBarRefreshTestDaemon(r, time.Second)
174+ sess := newBarRefreshTestSession()
175+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"}
176+
177+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
178+
179+ state := d.barStateFor(sess, "")
180+ require.Equal(t, "top-good", state.topRight)
181+ require.Equal(t, "bottom-good", state.bottomRight)
182+}
183+
184+func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
185+ var logs bytes.Buffer
186+ r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
187+ d := newBarRefreshTestDaemon(r, time.Second)
188+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
189+ sess := newBarRefreshTestSession()
190+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
191+
192+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
193+ waitBarRefreshIdle(t, d)
194+ r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")}
195+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
196+ waitBarRefreshIdle(t, d)
197+
198+ state := d.barStateFor(sess, "")
199+ require.Equal(t, "top-good", state.topRight)
200+ require.Equal(t, "bottom-good", state.bottomRight)
201+ require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String())
202+ require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String())
203+}
204+
205+func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
206+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
207+ d := newBarRefreshTestDaemon(r, time.Second)
208+ sess := newBarRefreshTestSession()
209+ d.barScripts.mu.Lock()
210+ d.barScripts.running[sess.id] = true
211+ d.barScripts.mu.Unlock()
212+
213+ d.clearBarScriptsForSession(sess.id)
214+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0)
215+
216+ state := d.barStateFor(sess, "")
217+ require.Empty(t, state.topRight)
218+ require.Empty(t, state.bottomRight)
219+}
220+
221+func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
222+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
223+ d := newBarRefreshTestDaemon(r, time.Second)
224+ sess := newBarRefreshTestSession()
225+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
226+
227+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
228+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
229+ waitBarRefreshIdle(t, d)
230+ require.Eventually(t, func() bool {
231+ r.mu.Lock()
232+ defer r.mu.Unlock()
233+ return len(r.calls) == 4
234+ }, time.Second, time.Millisecond)
235+}
236+
237+func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
238+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
239+ d := newBarRefreshTestDaemon(r, time.Second)
240+ sess := newBarRefreshTestSession()
241+ d.barScripts.mu.Lock()
242+ d.barScripts.running[sess.id] = true
243+ d.barScripts.version = 2
244+ d.barScripts.mu.Unlock()
245+
246+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1)
247+
248+ state := d.barStateFor(sess, "")
249+ require.Empty(t, state.topRight)
250+ require.Empty(t, state.bottomRight)
251+}
252+
253+func TestBarScriptRefreshIsPerSession(t *testing.T) {
254+ r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
255+ d := newBarRefreshTestDaemon(r, time.Second)
256+ a := newBarRefreshTestSession()
257+ a.id = "a"
258+ a.name = "a"
259+ a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
260+ b := newBarRefreshTestSession()
261+ b.id = "b"
262+ b.name = "b"
263+ b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
264+
265+ require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
266+ waitBarRefreshIdle(t, d)
267+ require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false))
268+ waitBarRefreshIdle(t, d)
269+
270+ stateA := d.barStateFor(a, "")
271+ stateB := d.barStateFor(b, "")
272+ require.Equal(t, "top-a", stateA.topRight)
273+ require.Equal(t, "bottom-a", stateA.bottomRight)
274+ require.Equal(t, "top-b", stateB.topRight)
275+ require.Equal(t, "bottom-b", stateB.bottomRight)
276+}
277+
278+func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
279+ d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
280+ cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
281+ runner: r,
282+ outputs: make(map[domain.SessionID]barScriptOutputs),
283+ lastRefresh: make(map[domain.SessionID]time.Time),
284+ lastContext: make(map[domain.SessionID]barScriptContext),
285+ running: make(map[domain.SessionID]bool),
286+ pending: make(map[domain.SessionID]bool),
287+ }}
288+ return d
289+}
290+
291+type barRefreshTestClock struct{}
292+
293+func (barRefreshTestClock) Now() time.Time { return time.Now() }
294+func (barRefreshTestClock) NewTimer(d time.Duration) ports.Timer {
295+ return realTimer{t: time.NewTimer(d)}
296+}
297+
298+func newBarRefreshTestSession() *session {
299+ inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
300+ active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
301+ active.tree.Focus = layout.PaneID("pane-1")
302+ return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
303+}
304+
305+func waitBarRefreshIdle(t *testing.T, d *Daemon) {
306+ t.Helper()
307+ require.Eventually(t, func() bool {
308+ d.barScripts.mu.Lock()
309+ defer d.barScripts.mu.Unlock()
310+ return len(d.barScripts.running) == 0
311+ }, time.Second, time.Millisecond)
312+}
@@ -0,0 +1,312 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "log/slog"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/layout"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+type fakeBarRunner struct {
20+ mu sync.Mutex
21+ calls []barScriptContext
22+ outs []string
23+ errs []error
24+}
25+
26+func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) {
27+ r.mu.Lock()
28+ defer r.mu.Unlock()
29+ r.calls = append(r.calls, ctx)
30+ i := len(r.calls) - 1
31+ if i < len(r.errs) && r.errs[i] != nil {
32+ return "", r.errs[i]
33+ }
34+ if i < len(r.outs) {
35+ return r.outs[i], nil
36+ }
37+ return "", nil
38+}
39+
40+func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
41+ r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
42+ d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
43+ sess := newBarRefreshTestSession()
44+ sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}
45+
46+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
47+ waitBarRefreshIdle(t, d)
48+ require.Equal(t, 2, len(r.calls))
49+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
50+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick")
51+
52+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
53+ waitBarRefreshIdle(t, d)
54+ require.Equal(t, 4, len(r.calls))
55+ require.Equal(t, "top2", d.barStateFor(sess, "").topRight)
56+
57+ r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")}
58+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false))
59+ waitBarRefreshIdle(t, d)
60+ state := d.barStateFor(sess, "")
61+ require.Equal(t, "top2", state.topRight)
62+ require.Equal(t, "bot2", state.bottomRight)
63+}
64+
65+func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
66+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
67+ d := newBarRefreshTestDaemon(r, time.Second)
68+ sess := newBarRefreshTestSession()
69+ sess.name = "work"
70+ sess.cwd = "/repo"
71+ sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}}
72+ sess.active = 1
73+ sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
74+ active := sess.tabs[1].focusedPane()
75+ active.stableID = "pane-active"
76+ active.pty = newScriptPTY(nil)
77+ d.procCwd = func(pid int) (string, error) {
78+ require.Equal(t, 4242, pid)
79+ return "/pane-repo", nil
80+ }
81+
82+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
83+ waitBarRefreshIdle(t, d)
84+ require.Len(t, r.calls, 2)
85+ for _, call := range r.calls {
86+ require.Equal(t, "work", call.Session)
87+ require.Equal(t, "tab-active", call.Tab)
88+ require.Equal(t, "pane-active", call.Pane)
89+ require.Equal(t, "/pane-repo", call.PaneCWD)
90+ require.Equal(t, 132, call.Cols)
91+ }
92+ require.Equal(t, "top-right", r.calls[0].Anchor)
93+ require.Equal(t, "bottom-right", r.calls[1].Anchor)
94+}
95+
96+func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
97+ r := &fakeBarRunner{}
98+ d := newBarRefreshTestDaemon(r, time.Second)
99+ sess := newBarRefreshTestSession()
100+
101+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
102+ require.Empty(t, r.calls)
103+}
104+
105+func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
106+ r := &fakeBarRunner{}
107+ d := newBarRefreshTestDaemon(r, time.Second)
108+ d.barScripts.cfg.topRight = ""
109+ d.barScripts.cfg.bottomRight = ""
110+ sess := newBarRefreshTestSession()
111+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
112+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
113+
114+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
115+ require.Empty(t, r.calls)
116+ state := d.barStateFor(sess, "")
117+ require.Empty(t, state.topRight)
118+ require.Empty(t, state.bottomRight)
119+}
120+
121+func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
122+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
123+ d := newBarRefreshTestDaemon(r, 5*time.Second)
124+ sess := newBarRefreshTestSession()
125+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
126+
127+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
128+ waitBarRefreshIdle(t, d)
129+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
130+ require.Len(t, r.calls, 2)
131+ require.Eventually(t, func() bool {
132+ r.mu.Lock()
133+ defer r.mu.Unlock()
134+ return len(r.calls) == 4
135+ }, 1500*time.Millisecond, 10*time.Millisecond)
136+}
137+
138+func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
139+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
140+ d := newBarRefreshTestDaemon(r, 5*time.Second)
141+ sess := newBarRefreshTestSession()
142+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
143+
144+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
145+ waitBarRefreshIdle(t, d)
146+ sess.cwd = "/other"
147+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false))
148+ require.Eventually(t, func() bool {
149+ r.mu.Lock()
150+ defer r.mu.Unlock()
151+ return len(r.calls) == 4
152+ }, 1500*time.Millisecond, 10*time.Millisecond)
153+ require.Equal(t, "/other", r.calls[2].PaneCWD)
154+}
155+
156+func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
157+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
158+ d := newBarRefreshTestDaemon(r, 5*time.Second)
159+ sess := newBarRefreshTestSession()
160+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
161+
162+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
163+ waitBarRefreshIdle(t, d)
164+ sess.cwd = "/other"
165+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
166+ waitBarRefreshIdle(t, d)
167+ require.Len(t, r.calls, 4)
168+ require.Equal(t, "/other", r.calls[2].PaneCWD)
169+}
170+
171+func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
172+ r := &fakeBarRunner{}
173+ d := newBarRefreshTestDaemon(r, time.Second)
174+ sess := newBarRefreshTestSession()
175+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"}
176+
177+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
178+
179+ state := d.barStateFor(sess, "")
180+ require.Equal(t, "top-good", state.topRight)
181+ require.Equal(t, "bottom-good", state.bottomRight)
182+}
183+
184+func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
185+ var logs bytes.Buffer
186+ r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
187+ d := newBarRefreshTestDaemon(r, time.Second)
188+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
189+ sess := newBarRefreshTestSession()
190+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
191+
192+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
193+ waitBarRefreshIdle(t, d)
194+ r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")}
195+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
196+ waitBarRefreshIdle(t, d)
197+
198+ state := d.barStateFor(sess, "")
199+ require.Equal(t, "top-good", state.topRight)
200+ require.Equal(t, "bottom-good", state.bottomRight)
201+ require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String())
202+ require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String())
203+}
204+
205+func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
206+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
207+ d := newBarRefreshTestDaemon(r, time.Second)
208+ sess := newBarRefreshTestSession()
209+ d.barScripts.mu.Lock()
210+ d.barScripts.running[sess.id] = true
211+ d.barScripts.mu.Unlock()
212+
213+ d.clearBarScriptsForSession(sess.id)
214+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0)
215+
216+ state := d.barStateFor(sess, "")
217+ require.Empty(t, state.topRight)
218+ require.Empty(t, state.bottomRight)
219+}
220+
221+func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
222+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
223+ d := newBarRefreshTestDaemon(r, time.Second)
224+ sess := newBarRefreshTestSession()
225+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
226+
227+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
228+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
229+ waitBarRefreshIdle(t, d)
230+ require.Eventually(t, func() bool {
231+ r.mu.Lock()
232+ defer r.mu.Unlock()
233+ return len(r.calls) == 4
234+ }, time.Second, time.Millisecond)
235+}
236+
237+func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
238+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
239+ d := newBarRefreshTestDaemon(r, time.Second)
240+ sess := newBarRefreshTestSession()
241+ d.barScripts.mu.Lock()
242+ d.barScripts.running[sess.id] = true
243+ d.barScripts.version = 2
244+ d.barScripts.mu.Unlock()
245+
246+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1)
247+
248+ state := d.barStateFor(sess, "")
249+ require.Empty(t, state.topRight)
250+ require.Empty(t, state.bottomRight)
251+}
252+
253+func TestBarScriptRefreshIsPerSession(t *testing.T) {
254+ r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
255+ d := newBarRefreshTestDaemon(r, time.Second)
256+ a := newBarRefreshTestSession()
257+ a.id = "a"
258+ a.name = "a"
259+ a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
260+ b := newBarRefreshTestSession()
261+ b.id = "b"
262+ b.name = "b"
263+ b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
264+
265+ require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
266+ waitBarRefreshIdle(t, d)
267+ require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false))
268+ waitBarRefreshIdle(t, d)
269+
270+ stateA := d.barStateFor(a, "")
271+ stateB := d.barStateFor(b, "")
272+ require.Equal(t, "top-a", stateA.topRight)
273+ require.Equal(t, "bottom-a", stateA.bottomRight)
274+ require.Equal(t, "top-b", stateB.topRight)
275+ require.Equal(t, "bottom-b", stateB.bottomRight)
276+}
277+
278+func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
279+ d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
280+ cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
281+ runner: r,
282+ outputs: make(map[domain.SessionID]barScriptOutputs),
283+ lastRefresh: make(map[domain.SessionID]time.Time),
284+ lastContext: make(map[domain.SessionID]barScriptContext),
285+ running: make(map[domain.SessionID]bool),
286+ pending: make(map[domain.SessionID]bool),
287+ }}
288+ return d
289+}
290+
291+type barRefreshTestClock struct{}
292+
293+func (barRefreshTestClock) Now() time.Time { return time.Now() }
294+func (barRefreshTestClock) NewTimer(d time.Duration) ports.Timer {
295+ return realTimer{t: time.NewTimer(d)}
296+}
297+
298+func newBarRefreshTestSession() *session {
299+ inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
300+ active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
301+ active.tree.Focus = layout.PaneID("pane-1")
302+ return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
303+}
304+
305+func waitBarRefreshIdle(t *testing.T, d *Daemon) {
306+ t.Helper()
307+ require.Eventually(t, func() bool {
308+ d.barScripts.mu.Lock()
309+ defer d.barScripts.mu.Unlock()
310+ return len(d.barScripts.running) == 0
311+ }, time.Second, time.Millisecond)
312+}
A internal/usecase/daemon/bar_script.go +183 -0
@@ -0,0 +1,183 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "strconv"
8+ "strings"
9+ "time"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/vev/internal/ports"
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+ runner ports.ShellCommandRunner
57+ timeout time.Duration
58+ baseEnv []string
59+}
60+
61+func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) {
62+ if strings.TrimSpace(command) == "" {
63+ return "", nil
64+ }
65+ if r.runner == nil {
66+ return "", errors.New("bar script command runner is nil")
67+ }
68+ timeout := r.timeout
69+ if timeout <= 0 {
70+ timeout = barScriptTimeout
71+ }
72+ ctx, cancel := context.WithTimeout(ctx, timeout)
73+ defer cancel()
74+
75+ stdout, err := r.runner.Run(ctx, ports.CommandSpec{
76+ Command: command,
77+ Env: scriptCtx.env(r.baseEnv),
78+ Timeout: timeout,
79+ StdoutLimit: barScriptOutputLimit,
80+ })
81+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
82+ return "", context.DeadlineExceeded
83+ }
84+ text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit)
85+ if err != nil {
86+ return text, err
87+ }
88+ return text, nil
89+}
90+
91+func sanitizeBarScriptOutput(raw []byte, limit int) string {
92+ if limit <= 0 || limit > len(raw) {
93+ limit = len(raw)
94+ }
95+ raw = raw[:limit]
96+ if i := bytes.IndexAny(raw, "\r\n"); i >= 0 {
97+ raw = raw[:i]
98+ }
99+ text := strings.ToValidUTF8(string(raw), "")
100+ text = stripANSIAndControls(text)
101+ if len(text) <= barScriptDisplayLimit {
102+ return text
103+ }
104+ return trimUTF8Bytes(text, barScriptDisplayLimit)
105+}
106+
107+func stripANSIAndControls(s string) string {
108+ var out strings.Builder
109+ for i := 0; i < len(s); {
110+ r, size := utf8.DecodeRuneInString(s[i:])
111+ switch {
112+ case r == '\x1b':
113+ i = skipEscapeSequence(s, i+size)
114+ continue
115+ case r == 0x9b:
116+ i = skipCSI(s, i+size)
117+ continue
118+ case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f:
119+ i = skipStringControl(s, i+size)
120+ continue
121+ case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
122+ i += size
123+ continue
124+ }
125+ out.WriteRune(r)
126+ i += size
127+ }
128+ return out.String()
129+}
130+
131+func skipEscapeSequence(s string, i int) int {
132+ if i >= len(s) {
133+ return i
134+ }
135+ switch s[i] {
136+ case '[':
137+ return skipCSI(s, i+1)
138+ case ']', 'P', '^', '_', 'X':
139+ return skipStringControl(s, i+1)
140+ }
141+ for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
142+ i++
143+ }
144+ if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e {
145+ return i + 1
146+ }
147+ return i
148+}
149+
150+func skipCSI(s string, i int) int {
151+ for i < len(s) {
152+ c := s[i]
153+ i++
154+ if c >= 0x40 && c <= 0x7e {
155+ break
156+ }
157+ }
158+ return i
159+}
160+
161+func skipStringControl(s string, i int) int {
162+ for i < len(s) {
163+ if s[i] == '\a' {
164+ return i + 1
165+ }
166+ if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
167+ return i + 2
168+ }
169+ _, size := utf8.DecodeRuneInString(s[i:])
170+ i += size
171+ }
172+ return i
173+}
174+
175+func trimUTF8Bytes(s string, limit int) string {
176+ if len(s) <= limit {
177+ return s
178+ }
179+ for limit > 0 && !utf8.ValidString(s[:limit]) {
180+ limit--
181+ }
182+ return s[:limit]
183+}
@@ -0,0 +1,183 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "strconv"
8+ "strings"
9+ "time"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/vev/internal/ports"
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+ runner ports.ShellCommandRunner
57+ timeout time.Duration
58+ baseEnv []string
59+}
60+
61+func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) {
62+ if strings.TrimSpace(command) == "" {
63+ return "", nil
64+ }
65+ if r.runner == nil {
66+ return "", errors.New("bar script command runner is nil")
67+ }
68+ timeout := r.timeout
69+ if timeout <= 0 {
70+ timeout = barScriptTimeout
71+ }
72+ ctx, cancel := context.WithTimeout(ctx, timeout)
73+ defer cancel()
74+
75+ stdout, err := r.runner.Run(ctx, ports.CommandSpec{
76+ Command: command,
77+ Env: scriptCtx.env(r.baseEnv),
78+ Timeout: timeout,
79+ StdoutLimit: barScriptOutputLimit,
80+ })
81+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
82+ return "", context.DeadlineExceeded
83+ }
84+ text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit)
85+ if err != nil {
86+ return text, err
87+ }
88+ return text, nil
89+}
90+
91+func sanitizeBarScriptOutput(raw []byte, limit int) string {
92+ if limit <= 0 || limit > len(raw) {
93+ limit = len(raw)
94+ }
95+ raw = raw[:limit]
96+ if i := bytes.IndexAny(raw, "\r\n"); i >= 0 {
97+ raw = raw[:i]
98+ }
99+ text := strings.ToValidUTF8(string(raw), "")
100+ text = stripANSIAndControls(text)
101+ if len(text) <= barScriptDisplayLimit {
102+ return text
103+ }
104+ return trimUTF8Bytes(text, barScriptDisplayLimit)
105+}
106+
107+func stripANSIAndControls(s string) string {
108+ var out strings.Builder
109+ for i := 0; i < len(s); {
110+ r, size := utf8.DecodeRuneInString(s[i:])
111+ switch {
112+ case r == '\x1b':
113+ i = skipEscapeSequence(s, i+size)
114+ continue
115+ case r == 0x9b:
116+ i = skipCSI(s, i+size)
117+ continue
118+ case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f:
119+ i = skipStringControl(s, i+size)
120+ continue
121+ case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
122+ i += size
123+ continue
124+ }
125+ out.WriteRune(r)
126+ i += size
127+ }
128+ return out.String()
129+}
130+
131+func skipEscapeSequence(s string, i int) int {
132+ if i >= len(s) {
133+ return i
134+ }
135+ switch s[i] {
136+ case '[':
137+ return skipCSI(s, i+1)
138+ case ']', 'P', '^', '_', 'X':
139+ return skipStringControl(s, i+1)
140+ }
141+ for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
142+ i++
143+ }
144+ if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e {
145+ return i + 1
146+ }
147+ return i
148+}
149+
150+func skipCSI(s string, i int) int {
151+ for i < len(s) {
152+ c := s[i]
153+ i++
154+ if c >= 0x40 && c <= 0x7e {
155+ break
156+ }
157+ }
158+ return i
159+}
160+
161+func skipStringControl(s string, i int) int {
162+ for i < len(s) {
163+ if s[i] == '\a' {
164+ return i + 1
165+ }
166+ if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
167+ return i + 2
168+ }
169+ _, size := utf8.DecodeRuneInString(s[i:])
170+ i += size
171+ }
172+ return i
173+}
174+
175+func trimUTF8Bytes(s string, limit int) string {
176+ if len(s) <= limit {
177+ return s
178+ }
179+ for limit > 0 && !utf8.ValidString(s[:limit]) {
180+ limit--
181+ }
182+ return s[:limit]
183+}
A internal/usecase/daemon/bar_script_test.go +182 -0
@@ -0,0 +1,182 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "slices"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+func TestBarScriptSanitizeOutput(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ raw string
20+ limit int
21+ want string
22+ }{
23+ {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
24+ {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
25+ {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
26+ {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},
27+ {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"},
28+ {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"},
29+ {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
30+ {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
31+ {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
32+ {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want {
38+ t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want)
39+ }
40+ })
41+ }
42+}
43+
44+func TestBarScriptContextEnv(t *testing.T) {
45+ base := []string{
46+ "PATH=/bin",
47+ "VEV_ANCHOR=old",
48+ "VEV_SESSION=old",
49+ "VEV_TAB=old",
50+ "VEV_PANE=old",
51+ "VEV_PANE_CWD=/old",
52+ "VEV_COLS=1",
53+ }
54+ ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}
55+ got := ctx.env(base)
56+
57+ want := map[string]string{
58+ "VEV_ANCHOR": "top-right",
59+ "VEV_SESSION": "work",
60+ "VEV_TAB": "2",
61+ "VEV_PANE": "pane-3",
62+ "VEV_PANE_CWD": "/repo",
63+ "VEV_COLS": "120",
64+ }
65+ seen := map[string]int{}
66+ for _, entry := range got {
67+ key, val, ok := strings.Cut(entry, "=")
68+ if !ok {
69+ continue
70+ }
71+ if _, isVEV := want[key]; isVEV {
72+ seen[key]++
73+ if val != want[key] {
74+ t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got)
75+ }
76+ }
77+ }
78+ for key := range want {
79+ if seen[key] != 1 {
80+ t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got)
81+ }
82+ }
83+}
84+
85+func TestBarScriptRunner(t *testing.T) {
86+ t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87+ runner := barScriptRunner{}
88+ got, err := runner.run(context.Background(), "date", barScriptContext{})
89+ if err == nil || err.Error() != "bar script command runner is nil" {
90+ t.Fatalf("run err = %v, want explicit nil runner error", err)
91+ }
92+ if got != "" {
93+ t.Fatalf("run output = %q, want empty", got)
94+ }
95+ })
96+
97+ t.Run("empty command skips port runner", func(t *testing.T) {
98+ runner := barScriptRunner{}
99+ got, err := runner.run(context.Background(), " \t\n ", barScriptContext{})
100+ if err != nil {
101+ t.Fatalf("run err = %v, want nil", err)
102+ }
103+ if got != "" {
104+ t.Fatalf("run output = %q, want empty", got)
105+ }
106+ })
107+
108+ t.Run("success sanitizes first line and passes command spec", func(t *testing.T) {
109+ portRunner := portsmocks.NewMockShellCommandRunner(t)
110+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
111+ return spec.Command == "printf 'ok\\nignored'" &&
112+ spec.Timeout == 50*time.Millisecond &&
113+ spec.StdoutLimit == barScriptOutputLimit &&
114+ containsEnv(spec.Env, "PATH=/bin") &&
115+ containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
116+ containsEnv(spec.Env, "VEV_SESSION=work") &&
117+ containsEnv(spec.Env, "VEV_TAB=tab-1") &&
118+ containsEnv(spec.Env, "VEV_PANE=pane-1") &&
119+ containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120+ containsEnv(spec.Env, "VEV_COLS=120") &&
121+ !containsEnv(spec.Env, "VEV_ANCHOR=old")
122+ })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil)
123+ runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}
124+
125+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
126+ if err != nil {
127+ t.Fatalf("run err = %v, want nil", err)
128+ }
129+ if got != "ok" {
130+ t.Fatalf("run output = %q, want %q", got, "ok")
131+ }
132+ })
133+
134+ t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
135+ wantErr := errors.New("exit 1")
136+ portRunner := portsmocks.NewMockShellCommandRunner(t)
137+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr)
138+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
139+
140+ got, err := runner.run(context.Background(), "false", barScriptContext{})
141+ if !errors.Is(err, wantErr) {
142+ t.Fatalf("run err = %v, want %v", err, wantErr)
143+ }
144+ if got != "partial output" {
145+ t.Fatalf("run output = %q, want %q", got, "partial output")
146+ }
147+ })
148+
149+ t.Run("timeout propagates deadline", func(t *testing.T) {
150+ portRunner := portsmocks.NewMockShellCommandRunner(t)
151+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {
152+ <-ctx.Done()
153+ return nil, ctx.Err()
154+ })
155+ runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
156+
157+ _, err := runner.run(context.Background(), "sleep 1", barScriptContext{})
158+ if !errors.Is(err, context.DeadlineExceeded) {
159+ t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
160+ }
161+ })
162+}
163+
164+func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
165+ portRunner := portsmocks.NewMockShellCommandRunner(t)
166+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
167+ return spec.StdoutLimit == barScriptOutputLimit
168+ })).Return([]byte(strings.Repeat("a", 4096)), nil)
169+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
170+
171+ got, err := runner.run(context.Background(), "long-output", barScriptContext{})
172+ if err != nil {
173+ t.Fatalf("run bounded output: %v", err)
174+ }
175+ if len(got) > barScriptDisplayLimit {
176+ t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
177+ }
178+}
179+
180+func containsEnv(env []string, entry string) bool {
181+ return slices.Contains(env, entry)
182+}
@@ -0,0 +1,182 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "slices"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+func TestBarScriptSanitizeOutput(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ raw string
20+ limit int
21+ want string
22+ }{
23+ {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
24+ {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
25+ {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
26+ {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},
27+ {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"},
28+ {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"},
29+ {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
30+ {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
31+ {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
32+ {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want {
38+ t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want)
39+ }
40+ })
41+ }
42+}
43+
44+func TestBarScriptContextEnv(t *testing.T) {
45+ base := []string{
46+ "PATH=/bin",
47+ "VEV_ANCHOR=old",
48+ "VEV_SESSION=old",
49+ "VEV_TAB=old",
50+ "VEV_PANE=old",
51+ "VEV_PANE_CWD=/old",
52+ "VEV_COLS=1",
53+ }
54+ ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}
55+ got := ctx.env(base)
56+
57+ want := map[string]string{
58+ "VEV_ANCHOR": "top-right",
59+ "VEV_SESSION": "work",
60+ "VEV_TAB": "2",
61+ "VEV_PANE": "pane-3",
62+ "VEV_PANE_CWD": "/repo",
63+ "VEV_COLS": "120",
64+ }
65+ seen := map[string]int{}
66+ for _, entry := range got {
67+ key, val, ok := strings.Cut(entry, "=")
68+ if !ok {
69+ continue
70+ }
71+ if _, isVEV := want[key]; isVEV {
72+ seen[key]++
73+ if val != want[key] {
74+ t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got)
75+ }
76+ }
77+ }
78+ for key := range want {
79+ if seen[key] != 1 {
80+ t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got)
81+ }
82+ }
83+}
84+
85+func TestBarScriptRunner(t *testing.T) {
86+ t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87+ runner := barScriptRunner{}
88+ got, err := runner.run(context.Background(), "date", barScriptContext{})
89+ if err == nil || err.Error() != "bar script command runner is nil" {
90+ t.Fatalf("run err = %v, want explicit nil runner error", err)
91+ }
92+ if got != "" {
93+ t.Fatalf("run output = %q, want empty", got)
94+ }
95+ })
96+
97+ t.Run("empty command skips port runner", func(t *testing.T) {
98+ runner := barScriptRunner{}
99+ got, err := runner.run(context.Background(), " \t\n ", barScriptContext{})
100+ if err != nil {
101+ t.Fatalf("run err = %v, want nil", err)
102+ }
103+ if got != "" {
104+ t.Fatalf("run output = %q, want empty", got)
105+ }
106+ })
107+
108+ t.Run("success sanitizes first line and passes command spec", func(t *testing.T) {
109+ portRunner := portsmocks.NewMockShellCommandRunner(t)
110+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
111+ return spec.Command == "printf 'ok\\nignored'" &&
112+ spec.Timeout == 50*time.Millisecond &&
113+ spec.StdoutLimit == barScriptOutputLimit &&
114+ containsEnv(spec.Env, "PATH=/bin") &&
115+ containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
116+ containsEnv(spec.Env, "VEV_SESSION=work") &&
117+ containsEnv(spec.Env, "VEV_TAB=tab-1") &&
118+ containsEnv(spec.Env, "VEV_PANE=pane-1") &&
119+ containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120+ containsEnv(spec.Env, "VEV_COLS=120") &&
121+ !containsEnv(spec.Env, "VEV_ANCHOR=old")
122+ })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil)
123+ runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}
124+
125+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
126+ if err != nil {
127+ t.Fatalf("run err = %v, want nil", err)
128+ }
129+ if got != "ok" {
130+ t.Fatalf("run output = %q, want %q", got, "ok")
131+ }
132+ })
133+
134+ t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
135+ wantErr := errors.New("exit 1")
136+ portRunner := portsmocks.NewMockShellCommandRunner(t)
137+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr)
138+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
139+
140+ got, err := runner.run(context.Background(), "false", barScriptContext{})
141+ if !errors.Is(err, wantErr) {
142+ t.Fatalf("run err = %v, want %v", err, wantErr)
143+ }
144+ if got != "partial output" {
145+ t.Fatalf("run output = %q, want %q", got, "partial output")
146+ }
147+ })
148+
149+ t.Run("timeout propagates deadline", func(t *testing.T) {
150+ portRunner := portsmocks.NewMockShellCommandRunner(t)
151+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {
152+ <-ctx.Done()
153+ return nil, ctx.Err()
154+ })
155+ runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
156+
157+ _, err := runner.run(context.Background(), "sleep 1", barScriptContext{})
158+ if !errors.Is(err, context.DeadlineExceeded) {
159+ t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
160+ }
161+ })
162+}
163+
164+func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
165+ portRunner := portsmocks.NewMockShellCommandRunner(t)
166+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
167+ return spec.StdoutLimit == barScriptOutputLimit
168+ })).Return([]byte(strings.Repeat("a", 4096)), nil)
169+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
170+
171+ got, err := runner.run(context.Background(), "long-output", barScriptContext{})
172+ if err != nil {
173+ t.Fatalf("run bounded output: %v", err)
174+ }
175+ if len(got) > barScriptDisplayLimit {
176+ t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
177+ }
178+}
179+
180+func containsEnv(env []string, entry string) bool {
181+ return slices.Contains(env, entry)
182+}
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 +15 -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,20 @@ 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.initLocked()
34+ barCfg := barConfigFromDomain(cfg.Bar)
35+ if d.barScripts.cfg != barCfg {
36+ d.barScripts.cfg = barCfg
37+ d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
38+ d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
39+ d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
40+ d.barScripts.pending = make(map[domain.SessionID]bool)
41+ d.barScripts.version++
42+ }
43+ d.barScripts.mu.Unlock()
44+ }
3045 d.reapplyThemeAllSessions()
3146 d.repaintAllAttachedClients()
3247 }
@@ -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,20 @@ 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.initLocked()
34+ barCfg := barConfigFromDomain(cfg.Bar)
35+ if d.barScripts.cfg != barCfg {
36+ d.barScripts.cfg = barCfg
37+ d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
38+ d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
39+ d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
40+ d.barScripts.pending = make(map[domain.SessionID]bool)
41+ d.barScripts.version++
42+ }
43+ d.barScripts.mu.Unlock()
44+ }
30 d.reapplyThemeAllSessions()45 d.reapplyThemeAllSessions()
31 d.repaintAllAttachedClients()46 d.repaintAllAttachedClients()
32 }47 }
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 +20 -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
@@ -224,6 +225,15 @@ func WithTempDir(dir string) Option {
224225 }
225226 }
226227
228+// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
229+func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
230+ return func(d *Daemon) {
231+ if runner != nil {
232+ d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv}
233+ }
234+ }
235+}
236+
227237 // WithConfig applies the initial user configuration.
228238 func WithConfig(cfg domain.Config) Option {
229239 return func(d *Daemon) {
@@ -255,6 +265,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
255265 done: make(chan struct{}),
256266 restoreDone: make(chan struct{}),
257267 animWake: make(chan struct{}, 1),
268+ barScripts: &barScriptState{
269+ cfg: barConfigFromDomain(domain.Defaults().Bar),
270+ outputs: make(map[domain.SessionID]barScriptOutputs),
271+ lastRefresh: make(map[domain.SessionID]time.Time),
272+ lastContext: make(map[domain.SessionID]barScriptContext),
273+ running: make(map[domain.SessionID]bool),
274+ },
258275 }
259276 for _, o := range opts {
260277 o(d)
@@ -303,6 +320,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
303320 d.sessWg.Go(func() {
304321 d.attentionAnimator(d.serveCtx)
305322 })
323+ d.sessWg.Go(func() {
324+ d.barScriptPoller(d.serveCtx)
325+ })
306326 if d.persistEnabled && d.procCwd != nil {
307327 d.sessWg.Go(func() {
308328 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
@@ -224,6 +225,15 @@ func WithTempDir(dir string) Option {
224 }225 }
225 }226 }
226 227
228+// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
229+func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
230+ return func(d *Daemon) {
231+ if runner != nil {
232+ d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv}
233+ }
234+ }
235+}
236+
227 // WithConfig applies the initial user configuration.237 // WithConfig applies the initial user configuration.
228 func WithConfig(cfg domain.Config) Option {238 func WithConfig(cfg domain.Config) Option {
229 return func(d *Daemon) {239 return func(d *Daemon) {
@@ -255,6 +265,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
255 done: make(chan struct{}),265 done: make(chan struct{}),
256 restoreDone: make(chan struct{}),266 restoreDone: make(chan struct{}),
257 animWake: make(chan struct{}, 1),267 animWake: make(chan struct{}, 1),
268+ barScripts: &barScriptState{
269+ cfg: barConfigFromDomain(domain.Defaults().Bar),
270+ outputs: make(map[domain.SessionID]barScriptOutputs),
271+ lastRefresh: make(map[domain.SessionID]time.Time),
272+ lastContext: make(map[domain.SessionID]barScriptContext),
273+ running: make(map[domain.SessionID]bool),
274+ },
258 }275 }
259 for _, o := range opts {276 for _, o := range opts {
260 o(d)277 o(d)
@@ -303,6 +320,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
303 d.sessWg.Go(func() {320 d.sessWg.Go(func() {
304 d.attentionAnimator(d.serveCtx)321 d.attentionAnimator(d.serveCtx)
305 })322 })
323+ d.sessWg.Go(func() {
324+ d.barScriptPoller(d.serveCtx)
325+ })
306 if d.persistEnabled && d.procCwd != nil {326 if d.persistEnabled && d.procCwd != nil {
307 d.sessWg.Go(func() {327 d.sessWg.Go(func() {
308 d.cwdSampler(d.serveCtx)328 d.cwdSampler(d.serveCtx)
M internal/usecase/daemon/daemon_test.go +4 -0
@@ -306,6 +306,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
306306 func rowText(row []renderer.Cell) string {
307307 runes := make([]rune, len(row))
308308 for i, c := range row {
309+ if c.Continuation {
310+ runes[i] = ' '
311+ continue
312+ }
309313 runes[i] = c.Rune
310314 }
311315 return string(runes)
@@ -306,6 +306,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
306 func rowText(row []renderer.Cell) string {306 func rowText(row []renderer.Cell) string {
307 runes := make([]rune, len(row))307 runes := make([]rune, len(row))
308 for i, c := range row {308 for i, c := range row {
309+ if c.Continuation {
310+ runes[i] = ' '
311+ continue
312+ }
309 runes[i] = c.Rune313 runes[i] = c.Rune
310 }314 }
311 return string(runes)315 return string(runes)
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 +45 -10
@@ -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,17 +96,36 @@ 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) {
101- if text == "" || len([]rune(text))+1+reservedLeft > len(row) {
113+ textWidth := statusTextWidth(text)
114+ if text == "" || textWidth+1+reservedLeft > len(row) {
102115 return
103116 }
104- x := len(row) - len([]rune(text)) - 1
117+ x := len(row) - textWidth - 1
105118 writeStatusText(row, &x, " "+text, style)
106119 }
107120
121+func statusTextWidth(text string) int {
122+ width := 0
123+ for _, r := range text {
124+ width += renderer.RuneWidth(r)
125+ }
126+ return width
127+}
128+
108129 func clearStatusRow(row []renderer.Cell) {
109130 for i := range row {
110131 row[i] = renderer.BlankCell()
@@ -113,6 +134,8 @@ func clearStatusRow(row []renderer.Cell) {
113134
114135 type barState struct {
115136 status statusSnapshot
137+ topRight string
138+ bottomRight string
116139 copyFeedback string
117140 mru []mruSession
118141 attentionFrame int
@@ -165,6 +188,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
165188 if cur != nil {
166189 state.status = cur.statusSegments()
167190 }
191+ if d != nil {
192+ state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
193+ }
168194 if d == nil {
169195 return state
170196 }
@@ -205,19 +231,20 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
205231 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
206232 copyReserve := 1
207233 if feedback != "" {
208- copyReserve = len([]rune(feedback)) + 2
234+ copyReserve = statusTextWidth(feedback) + 2
209235 }
210236 physicalBudget := rowLen - leftUsed - copyReserve
211237 if physicalBudget <= 0 || len(entries) == 0 {
212238 return nil
213239 }
214240 cost := func(e mruSession) int {
215- n := 2 + len([]rune(e.name))
241+ name := e.name
216242 if e.ephemeral {
217- n++
243+ name += "*"
218244 }
245+ n := 2 + statusTextWidth(name)
219246 if e.attention {
220- n += 2
247+ n += 1 + renderer.RuneWidth(attentionGlyph)
221248 }
222249 return n
223250 }
@@ -286,10 +313,18 @@ func writeBell(row []renderer.Cell, x *int, frame int) {
286313
287314 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
288315 for _, r := range text {
289- if *x >= len(row) {
316+ width := renderer.RuneWidth(r)
317+ if width == 0 {
318+ continue
319+ }
320+ if *x >= len(row) || *x+width > len(row) {
290321 return
291322 }
292323 row[*x] = renderer.Cell{Rune: r, Style: style}
293324 (*x)++
325+ if width == 2 {
326+ row[*x] = renderer.Cell{Style: style, Continuation: true}
327+ (*x)++
328+ }
294329 }
295330 }
@@ -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,17 +96,36 @@ 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) {
101- if text == "" || len([]rune(text))+1+reservedLeft > len(row) {113+ textWidth := statusTextWidth(text)
114+ if text == "" || textWidth+1+reservedLeft > len(row) {
102 return115 return
103 }116 }
104- x := len(row) - len([]rune(text)) - 1117+ x := len(row) - textWidth - 1
105 writeStatusText(row, &x, " "+text, style)118 writeStatusText(row, &x, " "+text, style)
106 }119 }
107 120
121+func statusTextWidth(text string) int {
122+ width := 0
123+ for _, r := range text {
124+ width += renderer.RuneWidth(r)
125+ }
126+ return width
127+}
128+
108 func clearStatusRow(row []renderer.Cell) {129 func clearStatusRow(row []renderer.Cell) {
109 for i := range row {130 for i := range row {
110 row[i] = renderer.BlankCell()131 row[i] = renderer.BlankCell()
@@ -113,6 +134,8 @@ func clearStatusRow(row []renderer.Cell) {
113 134
114 type barState struct {135 type barState struct {
115 status statusSnapshot136 status statusSnapshot
137+ topRight string
138+ bottomRight string
116 copyFeedback string139 copyFeedback string
117 mru []mruSession140 mru []mruSession
118 attentionFrame int141 attentionFrame int
@@ -165,6 +188,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
165 if cur != nil {188 if cur != nil {
166 state.status = cur.statusSegments()189 state.status = cur.statusSegments()
167 }190 }
191+ if d != nil {
192+ state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
193+ }
168 if d == nil {194 if d == nil {
169 return state195 return state
170 }196 }
@@ -205,19 +231,20 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
205 // its " text" width plus a one-cell gap so drawRightPlainText always fits.231 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
206 copyReserve := 1232 copyReserve := 1
207 if feedback != "" {233 if feedback != "" {
208- copyReserve = len([]rune(feedback)) + 2234+ copyReserve = statusTextWidth(feedback) + 2
209 }235 }
210 physicalBudget := rowLen - leftUsed - copyReserve236 physicalBudget := rowLen - leftUsed - copyReserve
211 if physicalBudget <= 0 || len(entries) == 0 {237 if physicalBudget <= 0 || len(entries) == 0 {
212 return nil238 return nil
213 }239 }
214 cost := func(e mruSession) int {240 cost := func(e mruSession) int {
215- n := 2 + len([]rune(e.name))241+ name := e.name
216 if e.ephemeral {242 if e.ephemeral {
217- n++243+ name += "*"
218 }244 }
245+ n := 2 + statusTextWidth(name)
219 if e.attention {246 if e.attention {
220- n += 2247+ n += 1 + renderer.RuneWidth(attentionGlyph)
221 }248 }
222 return n249 return n
223 }250 }
@@ -286,10 +313,18 @@ func writeBell(row []renderer.Cell, x *int, frame int) {
286 313
287 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {314 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
288 for _, r := range text {315 for _, r := range text {
289- if *x >= len(row) {316+ width := renderer.RuneWidth(r)
317+ if width == 0 {
318+ continue
319+ }
320+ if *x >= len(row) || *x+width > len(row) {
290 return321 return
291 }322 }
292 row[*x] = renderer.Cell{Rune: r, Style: style}323 row[*x] = renderer.Cell{Rune: r, Style: style}
293 (*x)++324 (*x)++
325+ if width == 2 {
326+ row[*x] = renderer.Cell{Style: style, Continuation: true}
327+ (*x)++
328+ }
294 }329 }
295 }330 }
M internal/usecase/daemon/status_test.go +128 -0
@@ -398,6 +398,52 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
398398 require.Equal(t, " 0* ", rowText(frame.Row(3)))
399399 }
400400
401+func TestTopBarRightAnchor(t *testing.T) {
402+ tests := []struct {
403+ name string
404+ width int
405+ status statusSnapshot
406+ topRight string
407+ want string
408+ continuationCell int
409+ }{
410+ {
411+ name: "renders flush right when fully fits",
412+ width: 20,
413+ status: statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
414+ topRight: "14:32",
415+ want: " 1 14:32",
416+ },
417+ {
418+ name: "hides on overlap",
419+ width: 10,
420+ status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},
421+ topRight: "12345",
422+ want: " 1 2 ",
423+ },
424+ {
425+ name: "uses display width",
426+ width: 12,
427+ status: statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
428+ topRight: "界a",
429+ want: " 1 界 a",
430+ continuationCell: 10,
431+ },
432+ }
433+ for _, tt := range tests {
434+ t.Run(tt.name, func(t *testing.T) {
435+ row := make([]renderer.Cell, tt.width)
436+
437+ drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil))
438+
439+ require.Equal(t, tt.want, rowText(row))
440+ if tt.continuationCell > 0 {
441+ require.True(t, row[tt.continuationCell].Continuation)
442+ }
443+ })
444+ }
445+}
446+
401447 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
402448 p, releasePTY := newBlockingPTY(t)
403449 _, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -644,6 +690,88 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
644690 }
645691 }
646692
693+func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
694+ tests := []struct {
695+ name string
696+ width int
697+ state barState
698+ want string
699+ wantContains []string
700+ wantNotContains []string
701+ wantSuffix string
702+ continuationCells []int
703+ }{
704+ {
705+ name: "script text and copy feedback",
706+ width: 32,
707+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}},
708+ wantContains: []string{" a"},
709+ wantSuffix: " main ↑3 * copied",
710+ },
711+ {
712+ name: "hide on overlap",
713+ width: 16,
714+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
715+ want: " cur ",
716+ wantNotContains: []string{"main", "copied"},
717+ },
718+ {
719+ name: "empty script keeps copy feedback behavior",
720+ width: 12,
721+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
722+ want: " cur copied",
723+ },
724+ {
725+ name: "mru whole entry fitting with script text",
726+ width: 24,
727+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
728+ wantContains: []string{" fresh"},
729+ wantNotContains: []string{"middle"},
730+ wantSuffix: " git",
731+ },
732+ {
733+ name: "mru fitting reserves wide right anchor width",
734+ width: 12,
735+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}},
736+ wantNotContains: []string{" a "},
737+ wantSuffix: " 界 界 ",
738+ continuationCells: []int{9, 11},
739+ },
740+ {
741+ name: "mru fitting counts wide session names",
742+ width: 20,
743+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "界"}, {name: "界"}, {name: "b"}}},
744+ wantContains: []string{" 界 "},
745+ wantNotContains: []string{" b "},
746+ wantSuffix: " git",
747+ },
748+ }
749+ for _, tt := range tests {
750+ t.Run(tt.name, func(t *testing.T) {
751+ row := make([]renderer.Cell, tt.width)
752+
753+ drawStatusBarState(row, tt.state, resolveThemeStyles(nil))
754+
755+ text := rowText(row)
756+ if tt.want != "" {
757+ require.Equal(t, tt.want, text)
758+ }
759+ for _, want := range tt.wantContains {
760+ require.Contains(t, text, want)
761+ }
762+ for _, hidden := range tt.wantNotContains {
763+ require.NotContains(t, text, hidden)
764+ }
765+ if tt.wantSuffix != "" {
766+ require.True(t, strings.HasSuffix(text, tt.wantSuffix), text)
767+ }
768+ for _, cell := range tt.continuationCells {
769+ require.True(t, row[cell].Continuation)
770+ }
771+ })
772+ }
773+}
774+
647775 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
648776 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
649777 row := make([]renderer.Cell, 20)
@@ -398,6 +398,52 @@ 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 TestTopBarRightAnchor(t *testing.T) {
402+ tests := []struct {
403+ name string
404+ width int
405+ status statusSnapshot
406+ topRight string
407+ want string
408+ continuationCell int
409+ }{
410+ {
411+ name: "renders flush right when fully fits",
412+ width: 20,
413+ status: statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
414+ topRight: "14:32",
415+ want: " 1 14:32",
416+ },
417+ {
418+ name: "hides on overlap",
419+ width: 10,
420+ status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},
421+ topRight: "12345",
422+ want: " 1 2 ",
423+ },
424+ {
425+ name: "uses display width",
426+ width: 12,
427+ status: statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
428+ topRight: "界a",
429+ want: " 1 界 a",
430+ continuationCell: 10,
431+ },
432+ }
433+ for _, tt := range tests {
434+ t.Run(tt.name, func(t *testing.T) {
435+ row := make([]renderer.Cell, tt.width)
436+
437+ drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil))
438+
439+ require.Equal(t, tt.want, rowText(row))
440+ if tt.continuationCell > 0 {
441+ require.True(t, row[tt.continuationCell].Continuation)
442+ }
443+ })
444+ }
445+}
446+
401 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {447 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
402 p, releasePTY := newBlockingPTY(t)448 p, releasePTY := newBlockingPTY(t)
403 _, sess, _, _ := newManualSessionWithPTYs(t, p)449 _, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -644,6 +690,88 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
644 }690 }
645 }691 }
646 692
693+func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
694+ tests := []struct {
695+ name string
696+ width int
697+ state barState
698+ want string
699+ wantContains []string
700+ wantNotContains []string
701+ wantSuffix string
702+ continuationCells []int
703+ }{
704+ {
705+ name: "script text and copy feedback",
706+ width: 32,
707+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}},
708+ wantContains: []string{" a"},
709+ wantSuffix: " main ↑3 * copied",
710+ },
711+ {
712+ name: "hide on overlap",
713+ width: 16,
714+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
715+ want: " cur ",
716+ wantNotContains: []string{"main", "copied"},
717+ },
718+ {
719+ name: "empty script keeps copy feedback behavior",
720+ width: 12,
721+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
722+ want: " cur copied",
723+ },
724+ {
725+ name: "mru whole entry fitting with script text",
726+ width: 24,
727+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
728+ wantContains: []string{" fresh"},
729+ wantNotContains: []string{"middle"},
730+ wantSuffix: " git",
731+ },
732+ {
733+ name: "mru fitting reserves wide right anchor width",
734+ width: 12,
735+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}},
736+ wantNotContains: []string{" a "},
737+ wantSuffix: " 界 界 ",
738+ continuationCells: []int{9, 11},
739+ },
740+ {
741+ name: "mru fitting counts wide session names",
742+ width: 20,
743+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "界"}, {name: "界"}, {name: "b"}}},
744+ wantContains: []string{" 界 "},
745+ wantNotContains: []string{" b "},
746+ wantSuffix: " git",
747+ },
748+ }
749+ for _, tt := range tests {
750+ t.Run(tt.name, func(t *testing.T) {
751+ row := make([]renderer.Cell, tt.width)
752+
753+ drawStatusBarState(row, tt.state, resolveThemeStyles(nil))
754+
755+ text := rowText(row)
756+ if tt.want != "" {
757+ require.Equal(t, tt.want, text)
758+ }
759+ for _, want := range tt.wantContains {
760+ require.Contains(t, text, want)
761+ }
762+ for _, hidden := range tt.wantNotContains {
763+ require.NotContains(t, text, hidden)
764+ }
765+ if tt.wantSuffix != "" {
766+ require.True(t, strings.HasSuffix(text, tt.wantSuffix), text)
767+ }
768+ for _, cell := range tt.continuationCells {
769+ require.True(t, row[cell].Continuation)
770+ }
771+ })
772+ }
773+}
774+
647 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {775 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
648 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}776 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
649 row := make([]renderer.Cell, 20)777 row := make([]renderer.Cell, 20)
A scripts/vev-bar-bottom-right +49 -0
@@ -0,0 +1,49 @@
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+ behind=$(printf '%s\n' "$counts" | awk '{ print $1 }')
30+ ahead=$(printf '%s\n' "$counts" | awk '{ print $2 }')
31+ case $ahead in
32+ ''|*[!0-9]*) ahead=0 ;;
33+ esac
34+ case $behind in
35+ ''|*[!0-9]*) behind=0 ;;
36+ esac
37+ if [ "$ahead" -gt 0 ] 2>/dev/null; then
38+ line="$line ↑$ahead"
39+ fi
40+ if [ "$behind" -gt 0 ] 2>/dev/null; then
41+ line="$line ↓$behind"
42+ fi
43+fi
44+
45+if [ -n "$(git -C "$VEV_PANE_CWD" status --porcelain 2>/dev/null || true)" ]; then
46+ line="$line *"
47+fi
48+
49+printf '%s\n' "$line"
@@ -0,0 +1,49 @@
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+ behind=$(printf '%s\n' "$counts" | awk '{ print $1 }')
30+ ahead=$(printf '%s\n' "$counts" | awk '{ print $2 }')
31+ case $ahead in
32+ ''|*[!0-9]*) ahead=0 ;;
33+ esac
34+ case $behind in
35+ ''|*[!0-9]*) behind=0 ;;
36+ esac
37+ if [ "$ahead" -gt 0 ] 2>/dev/null; then
38+ line="$line ↑$ahead"
39+ fi
40+ if [ "$behind" -gt 0 ] 2>/dev/null; then
41+ line="$line ↓$behind"
42+ fi
43+fi
44+
45+if [ -n "$(git -C "$VEV_PANE_CWD" status --porcelain 2>/dev/null || true)" ]; then
46+ line="$line *"
47+fi
48+
49+printf '%s\n' "$line"
A scripts/vev-bar-top-right +57 -0
@@ -0,0 +1,57 @@
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+ 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
26+fi
27+
28+mem_pct=
29+if [ -r /proc/meminfo ]; then
30+ mem_pct=$(
31+ awk '
32+ $1 == "MemTotal:" { total = $2 }
33+ $1 == "MemAvailable:" { available = $2 }
34+ END {
35+ if (total > 0 && available >= 0) printf "%d", ((total - available) * 100) / total
36+ }
37+ ' /proc/meminfo 2>/dev/null || true
38+ )
39+fi
40+
41+line=$now
42+if [ -n "$cpu_pct" ]; then
43+ if [ -n "$line" ]; then
44+ line="$line cpu $cpu_pct%"
45+ else
46+ line="cpu $cpu_pct%"
47+ fi
48+fi
49+if [ -n "$mem_pct" ]; then
50+ if [ -n "$line" ]; then
51+ line="$line mem $mem_pct%"
52+ else
53+ line="mem $mem_pct%"
54+ fi
55+fi
56+
57+printf '%s\n' "$line"
@@ -0,0 +1,57 @@
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+ 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
26+fi
27+
28+mem_pct=
29+if [ -r /proc/meminfo ]; then
30+ mem_pct=$(
31+ awk '
32+ $1 == "MemTotal:" { total = $2 }
33+ $1 == "MemAvailable:" { available = $2 }
34+ END {
35+ if (total > 0 && available >= 0) printf "%d", ((total - available) * 100) / total
36+ }
37+ ' /proc/meminfo 2>/dev/null || true
38+ )
39+fi
40+
41+line=$now
42+if [ -n "$cpu_pct" ]; then
43+ if [ -n "$line" ]; then
44+ line="$line cpu $cpu_pct%"
45+ else
46+ line="cpu $cpu_pct%"
47+ fi
48+fi
49+if [ -n "$mem_pct" ]; then
50+ if [ -n "$line" ]; then
51+ line="$line mem $mem_pct%"
52+ else
53+ line="mem $mem_pct%"
54+ fi
55+fi
56+
57+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