detached 731fc1d

Merge pull request #117 from bnema/fix/bar-script-session-env

fix(daemon): run bar scripts with the session environment
commit
731fc1de174a3e2c192e79c39b96da92c20dddd2
author
bnema · · 2026-07-20 09:53 UTC
committer
GitHub · · 2026-07-20 09:53 UTC
merge of
4904a5a + 67b1767 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +610 -71
M docs/configuration.md +3 -1
@@ -110,4 +110,6 @@ The command runs through your shell. A changed command applies on the next launc
110110
111111 Anchor commands run on the daemon host every `bar.interval` (minimum 1s). vev reads the first line of stdout, strips ANSI codes, and keeps the last good value on failure. Scripts get `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`.
112112
113-Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the daemon host's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository.
113+Commands resolve against the environment of the client currently attached to the session, the same environment new panes inherit. A command that works in your shell will work as an anchor without restarting the daemon. When an anchor fails, the daemon logs the exit code and the command's stderr; exit 127 means the command was not found on that `PATH`.
114+
115+Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the attaching client's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository.
@@ -110,4 +110,6 @@ The command runs through your shell. A changed command applies on the next launc
110 110
111 Anchor commands run on the daemon host every `bar.interval` (minimum 1s). vev reads the first line of stdout, strips ANSI codes, and keeps the last good value on failure. Scripts get `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`.111 Anchor commands run on the daemon host every `bar.interval` (minimum 1s). vev reads the first line of stdout, strips ANSI codes, and keeps the last good value on failure. Scripts get `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`.
112 112
113-Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the daemon host's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository.113+Commands resolve against the environment of the client currently attached to the session, the same environment new panes inherit. A command that works in your shell will work as an anchor without restarting the daemon. When an anchor fails, the daemon logs the exit code and the command's stderr; exit 127 means the command was not found on that `PATH`.
114+
115+Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the attaching client's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository.
M internal/adapters/shellcmd/runner.go +18 -7
@@ -3,6 +3,7 @@ package shellcmd
33 import (
44 "bytes"
55 "context"
6+ "errors"
67 "io"
78 "os/exec"
89 "syscall"
@@ -16,8 +17,8 @@ type Runner struct{}
1617 // New returns a production shell command runner.
1718 func New() Runner { return Runner{} }
1819
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) {
20+// Run executes spec.Command through sh -c, capturing bounded stdout and stderr.
21+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {
2122 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
2223 cmd.Env = spec.Env
2324 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -28,14 +29,24 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
2829 }
2930 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
3031 }
31- var stdout boundedBuffer
32+ var stdout, stderr boundedBuffer
3233 stdout.limit = spec.StdoutLimit
34+ stderr.limit = spec.StderrLimit
3335 cmd.Stdout = &stdout
34- cmd.Stderr = io.Discard
35- if err := cmd.Run(); err != nil {
36- return stdout.Bytes(), err
36+ cmd.Stderr = &stderr
37+
38+ err := cmd.Run()
39+ res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
40+ if err != nil {
41+ var exitErr *exec.ExitError
42+ if errors.As(err, &exitErr) {
43+ res.ExitCode = exitErr.ExitCode()
44+ } else {
45+ res.ExitCode = -1
46+ }
47+ return res, err
3748 }
38- return stdout.Bytes(), nil
49+ return res, nil
3950 }
4051
4152 type boundedBuffer struct {
@@ -3,6 +3,7 @@ package shellcmd
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "errors"
6 "io"7 "io"
7 "os/exec"8 "os/exec"
8 "syscall"9 "syscall"
@@ -16,8 +17,8 @@ type Runner struct{}
16 // New returns a production shell command runner.17 // New returns a production shell command runner.
17 func New() Runner { return Runner{} }18 func New() Runner { return Runner{} }
18 19
19-// Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr.20+// Run executes spec.Command through sh -c, capturing bounded stdout and stderr.
20-func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {21+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {
21 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)22 cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
22 cmd.Env = spec.Env23 cmd.Env = spec.Env
23 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}24 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -28,14 +29,24 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
28 }29 }
29 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)30 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
30 }31 }
31- var stdout boundedBuffer32+ var stdout, stderr boundedBuffer
32 stdout.limit = spec.StdoutLimit33 stdout.limit = spec.StdoutLimit
34+ stderr.limit = spec.StderrLimit
33 cmd.Stdout = &stdout35 cmd.Stdout = &stdout
34- cmd.Stderr = io.Discard36+ cmd.Stderr = &stderr
35- if err := cmd.Run(); err != nil {37+
36- return stdout.Bytes(), err38+ err := cmd.Run()
39+ res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
40+ if err != nil {
41+ var exitErr *exec.ExitError
42+ if errors.As(err, &exitErr) {
43+ res.ExitCode = exitErr.ExitCode()
44+ } else {
45+ res.ExitCode = -1
46+ }
47+ return res, err
37 }48 }
38- return stdout.Bytes(), nil49+ return res, nil
39 }50 }
40 51
41 type boundedBuffer struct {52 type boundedBuffer struct {
M internal/adapters/shellcmd/runner_test.go +70 -7
@@ -8,6 +8,7 @@ import (
88 "time"
99
1010 "github.com/bnema/vev/internal/ports"
11+ "github.com/stretchr/testify/require"
1112 )
1213
1314 func TestRunnerRun(t *testing.T) {
@@ -19,7 +20,7 @@ func TestRunnerRun(t *testing.T) {
1920 }{
2021 {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
2122 {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: "stderr does not leak into stdout", command: "printf out; printf err >&2", limit: 1024, want: "out"},
2324 {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
2425 {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
2526 }
@@ -32,8 +33,8 @@ func TestRunnerRun(t *testing.T) {
3233 if err != nil {
3334 t.Fatalf("Run() error = %v", err)
3435 }
35- if string(got) != tt.want {
36- t.Fatalf("Run() = %q, want %q", string(got), tt.want)
36+ if string(got.Stdout) != tt.want {
37+ t.Fatalf("Run() = %q, want %q", string(got.Stdout), tt.want)
3738 }
3839 })
3940 }
@@ -47,8 +48,8 @@ func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
4748 if err == nil {
4849 t.Fatal("Run() error = nil, want non-zero exit error")
4950 }
50- if string(got) != "abc" {
51- t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc")
51+ if string(got.Stdout) != "abc" {
52+ t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got.Stdout), "abc")
5253 }
5354 }
5455
@@ -101,7 +102,69 @@ func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
101102 if err != nil {
102103 t.Fatalf("Run() error = %v", err)
103104 }
104- if strings.TrimSpace(string(got)) != "from-env" {
105- t.Fatalf("Run() env output = %q, want from-env", string(got))
105+ if strings.TrimSpace(string(got.Stdout)) != "from-env" {
106+ t.Fatalf("Run() env output = %q, want from-env", string(got.Stdout))
106107 }
107108 }
109+
110+func TestRunnerCapturesStderrAndExitCode(t *testing.T) {
111+ tests := []struct {
112+ name string
113+ command string
114+ wantExitCode int
115+ wantStderr string
116+ wantStdout string
117+ }{
118+ {
119+ name: "command not found reports 127 and stderr",
120+ command: "definitely-not-a-real-command-xyz",
121+ wantExitCode: 127,
122+ wantStderr: "not found",
123+ },
124+ {
125+ name: "explicit exit code with stderr",
126+ command: "echo oops >&2; exit 3",
127+ wantExitCode: 3,
128+ wantStderr: "oops",
129+ },
130+ {
131+ name: "success reports zero and stdout",
132+ command: "echo hello",
133+ wantExitCode: 0,
134+ wantStdout: "hello",
135+ },
136+ }
137+
138+ for _, tc := range tests {
139+ t.Run(tc.name, func(t *testing.T) {
140+ res, err := New().Run(context.Background(), ports.CommandSpec{
141+ Command: tc.command,
142+ Timeout: 5 * time.Second,
143+ StdoutLimit: 1024,
144+ StderrLimit: 1024,
145+ })
146+ if tc.wantExitCode == 0 {
147+ require.NoError(t, err)
148+ } else {
149+ require.Error(t, err)
150+ }
151+ require.Equal(t, tc.wantExitCode, res.ExitCode)
152+ if tc.wantStderr != "" {
153+ require.Contains(t, string(res.Stderr), tc.wantStderr)
154+ }
155+ if tc.wantStdout != "" {
156+ require.Contains(t, string(res.Stdout), tc.wantStdout)
157+ }
158+ })
159+ }
160+}
161+
162+func TestRunnerBoundsStderr(t *testing.T) {
163+ res, _ := New().Run(context.Background(), ports.CommandSpec{
164+ Command: "yes badness 2>/dev/null | head -c 100000 >&2",
165+ Timeout: 5 * time.Second,
166+ StdoutLimit: 1024,
167+ StderrLimit: 64,
168+ })
169+ require.Equal(t, 64, len(res.Stderr), "stderr must be captured and truncated to StderrLimit")
170+}
@@ -8,6 +8,7 @@ import (
8 "time"8 "time"
9 9
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11+ "github.com/stretchr/testify/require"
11 )12 )
12 13
13 func TestRunnerRun(t *testing.T) {14 func TestRunnerRun(t *testing.T) {
@@ -19,7 +20,7 @@ func TestRunnerRun(t *testing.T) {
19 }{20 }{
20 {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},21 {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: "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: "stderr does not leak into stdout", command: "printf out; printf err >&2", limit: 1024, want: "out"},
23 {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},24 {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
24 {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},25 {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
25 }26 }
@@ -32,8 +33,8 @@ func TestRunnerRun(t *testing.T) {
32 if err != nil {33 if err != nil {
33 t.Fatalf("Run() error = %v", err)34 t.Fatalf("Run() error = %v", err)
34 }35 }
35- if string(got) != tt.want {36+ if string(got.Stdout) != tt.want {
36- t.Fatalf("Run() = %q, want %q", string(got), tt.want)37+ t.Fatalf("Run() = %q, want %q", string(got.Stdout), tt.want)
37 }38 }
38 })39 })
39 }40 }
@@ -47,8 +48,8 @@ func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
47 if err == nil {48 if err == nil {
48 t.Fatal("Run() error = nil, want non-zero exit error")49 t.Fatal("Run() error = nil, want non-zero exit error")
49 }50 }
50- if string(got) != "abc" {51+ if string(got.Stdout) != "abc" {
51- t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc")52+ t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got.Stdout), "abc")
52 }53 }
53 }54 }
54 55
@@ -101,7 +102,69 @@ func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
101 if err != nil {102 if err != nil {
102 t.Fatalf("Run() error = %v", err)103 t.Fatalf("Run() error = %v", err)
103 }104 }
104- if strings.TrimSpace(string(got)) != "from-env" {105+ if strings.TrimSpace(string(got.Stdout)) != "from-env" {
105- t.Fatalf("Run() env output = %q, want from-env", string(got))106+ t.Fatalf("Run() env output = %q, want from-env", string(got.Stdout))
106 }107 }
107 }108 }
109+
110+func TestRunnerCapturesStderrAndExitCode(t *testing.T) {
111+ tests := []struct {
112+ name string
113+ command string
114+ wantExitCode int
115+ wantStderr string
116+ wantStdout string
117+ }{
118+ {
119+ name: "command not found reports 127 and stderr",
120+ command: "definitely-not-a-real-command-xyz",
121+ wantExitCode: 127,
122+ wantStderr: "not found",
123+ },
124+ {
125+ name: "explicit exit code with stderr",
126+ command: "echo oops >&2; exit 3",
127+ wantExitCode: 3,
128+ wantStderr: "oops",
129+ },
130+ {
131+ name: "success reports zero and stdout",
132+ command: "echo hello",
133+ wantExitCode: 0,
134+ wantStdout: "hello",
135+ },
136+ }
137+
138+ for _, tc := range tests {
139+ t.Run(tc.name, func(t *testing.T) {
140+ res, err := New().Run(context.Background(), ports.CommandSpec{
141+ Command: tc.command,
142+ Timeout: 5 * time.Second,
143+ StdoutLimit: 1024,
144+ StderrLimit: 1024,
145+ })
146+ if tc.wantExitCode == 0 {
147+ require.NoError(t, err)
148+ } else {
149+ require.Error(t, err)
150+ }
151+ require.Equal(t, tc.wantExitCode, res.ExitCode)
152+ if tc.wantStderr != "" {
153+ require.Contains(t, string(res.Stderr), tc.wantStderr)
154+ }
155+ if tc.wantStdout != "" {
156+ require.Contains(t, string(res.Stdout), tc.wantStdout)
157+ }
158+ })
159+ }
160+}
161+
162+func TestRunnerBoundsStderr(t *testing.T) {
163+ res, _ := New().Run(context.Background(), ports.CommandSpec{
164+ Command: "yes badness 2>/dev/null | head -c 100000 >&2",
165+ Timeout: 5 * time.Second,
166+ StdoutLimit: 1024,
167+ StderrLimit: 64,
168+ })
169+ require.Equal(t, 64, len(res.Stderr), "stderr must be captured and truncated to StderrLimit")
170+}
M internal/ports/mocks/shell_mock.go +8 -10
@@ -39,24 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
3939 }
4040
4141 // Run provides a mock function for the type MockShellCommandRunner
42-func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
42+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {
4343 ret := _mock.Called(ctx, spec)
4444
4545 if len(ret) == 0 {
4646 panic("no return value specified for Run")
4747 }
4848
49- var r0 []byte
49+ var r0 ports.CommandResult
5050 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok {
5252 return returnFunc(ctx, spec)
5353 }
54- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok {
5555 r0 = returnFunc(ctx, spec)
5656 } else {
57- if ret.Get(0) != nil {
58- r0 = ret.Get(0).([]byte)
59- }
57+ r0 = ret.Get(0).(ports.CommandResult)
6058 }
6159 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
6260 r1 = returnFunc(ctx, spec)
@@ -96,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe
9694 return _c
9795 }
9896
99-func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {
100- _c.Call.Return(bytes, err)
97+func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call {
98+ _c.Call.Return(commandResult, err)
10199 return _c
102100 }
103101
104-func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {
102+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call {
105103 _c.Call.Return(run)
106104 return _c
107105 }
@@ -39,24 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
39 }39 }
40 40
41 // Run provides a mock function for the type MockShellCommandRunner41 // Run provides a mock function for the type MockShellCommandRunner
42-func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {42+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) {
43 ret := _mock.Called(ctx, spec)43 ret := _mock.Called(ctx, spec)
44 44
45 if len(ret) == 0 {45 if len(ret) == 0 {
46 panic("no return value specified for Run")46 panic("no return value specified for Run")
47 }47 }
48 48
49- var r0 []byte49+ var r0 ports.CommandResult
50 var r1 error50 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {51+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok {
52 return returnFunc(ctx, spec)52 return returnFunc(ctx, spec)
53 }53 }
54- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {54+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok {
55 r0 = returnFunc(ctx, spec)55 r0 = returnFunc(ctx, spec)
56 } else {56 } else {
57- if ret.Get(0) != nil {57+ r0 = ret.Get(0).(ports.CommandResult)
58- r0 = ret.Get(0).([]byte)
59- }
60 }58 }
61 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {59 if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
62 r1 = returnFunc(ctx, spec)60 r1 = returnFunc(ctx, spec)
@@ -96,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe
96 return _c94 return _c
97 }95 }
98 96
99-func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {97+func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call {
100- _c.Call.Return(bytes, err)98+ _c.Call.Return(commandResult, err)
101 return _c99 return _c
102 }100 }
103 101
104-func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {102+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call {
105 _c.Call.Return(run)103 _c.Call.Return(run)
106 return _c104 return _c
107 }105 }
M internal/ports/shell.go +13 -2
@@ -11,9 +11,20 @@ type CommandSpec struct {
1111 Env []string
1212 Timeout time.Duration
1313 StdoutLimit int
14+ StderrLimit int
1415 }
1516
16-// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
17+// CommandResult carries bounded output and the process exit status. ExitCode is
18+// 0 when the command ran to completion successfully, the process exit code when
19+// it ran and failed, and -1 when the command could not be started or was
20+// terminated by a signal.
21+type CommandResult struct {
22+ Stdout []byte
23+ Stderr []byte
24+ ExitCode int
25+}
26+
27+// ShellCommandRunner runs shell commands and returns bounded output.
1728 type ShellCommandRunner interface {
18- Run(ctx context.Context, spec CommandSpec) ([]byte, error)
29+ Run(ctx context.Context, spec CommandSpec) (CommandResult, error)
1930 }
@@ -11,9 +11,20 @@ type CommandSpec struct {
11 Env []string11 Env []string
12 Timeout time.Duration12 Timeout time.Duration
13 StdoutLimit int13 StdoutLimit int
14+ StderrLimit int
14 }15 }
15 16
16-// ShellCommandRunner runs shell commands and returns bounded stdout bytes.17+// CommandResult carries bounded output and the process exit status. ExitCode is
18+// 0 when the command ran to completion successfully, the process exit code when
19+// it ran and failed, and -1 when the command could not be started or was
20+// terminated by a signal.
21+type CommandResult struct {
22+ Stdout []byte
23+ Stderr []byte
24+ ExitCode int
25+}
26+
27+// ShellCommandRunner runs shell commands and returns bounded output.
17 type ShellCommandRunner interface {28 type ShellCommandRunner interface {
18- Run(ctx context.Context, spec CommandSpec) ([]byte, error)29+ Run(ctx context.Context, spec CommandSpec) (CommandResult, error)
19 }30 }
M internal/usecase/daemon/bar_refresh.go +129 -18
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "time"
78
@@ -9,9 +10,20 @@ import (
910 )
1011
1112 type barScriptExecutor interface {
12- run(context.Context, string, barScriptContext) (string, error)
13+ run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error)
1314 }
1415
16+// Anchor names, exported to bar scripts as VEV_ANCHOR and documented in
17+// docs/configuration.md. barAnchors must list every anchor so cleanup in
18+// clearBarScriptsForSession stays in sync with the anchors run in
19+// runBarScripts.
20+const (
21+ barAnchorTopRight = "top-right"
22+ barAnchorBottomRight = "bottom-right"
23+)
24+
25+var barAnchors = []string{barAnchorTopRight, barAnchorBottomRight}
26+
1527 type barScriptConfig struct {
1628 topRight string
1729 bottomRight string
@@ -23,6 +35,11 @@ type barScriptOutputs struct {
2335 bottomRight string
2436 }
2537
38+type barScriptFailureKey struct {
39+ id domain.SessionID
40+ anchor string
41+}
42+
2643 type barScriptState struct {
2744 mu sync.Mutex
2845 cfg barScriptConfig
@@ -32,7 +49,12 @@ type barScriptState struct {
3249 lastContext map[domain.SessionID]barScriptContext
3350 running map[domain.SessionID]bool
3451 pending map[domain.SessionID]bool
52+ lastFailure map[barScriptFailureKey]string
3553 version uint64
54+
55+ // reload wakes barScriptPoller so an interval change takes effect without
56+ // waiting out the timer armed under the previous interval.
57+ reload chan struct{}
3658 }
3759
3860 func effectiveBarInterval(d time.Duration) time.Duration {
@@ -65,6 +87,9 @@ func (s *barScriptState) initLocked() {
6587 if s.pending == nil {
6688 s.pending = make(map[domain.SessionID]bool)
6789 }
90+ if s.lastFailure == nil {
91+ s.lastFailure = make(map[barScriptFailureKey]string)
92+ }
6893 }
6994
7095 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
@@ -86,13 +111,17 @@ func (d *Daemon) barScriptInterval() time.Duration {
86111 return effectiveBarInterval(d.barScripts.cfg.interval)
87112 }
88113
89-func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) {
114+func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, []string, bool) {
90115 ctx := barScriptContext{Anchor: anchor}
91116 if sess == nil {
92- return ctx, false
117+ return ctx, nil, false
93118 }
94119 sess.mu.Lock()
95120 ctx.Session = sess.name
121+ // No copy needed: writers always replace sess.env with a whole fresh
122+ // slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123+ // backing array in place, so capturing the header here is safe.
124+ env := sess.env
96125 active := sess.active
97126 var tb *tab
98127 if active >= 0 && active < len(sess.tabs) {
@@ -102,7 +131,7 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
102131 ctx.PaneCWD = sess.cwd
103132 sess.mu.Unlock()
104133 if ac == nil || tb == nil {
105- return ctx, false
134+ return ctx, env, false
106135 }
107136 tb.mu.Lock()
108137 ctx.Tab = tb.stableID
@@ -120,25 +149,54 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
120149 ctx.PaneCWD = cwd
121150 }
122151 }
123- return ctx, true
152+ return ctx, env, true
153+}
154+
155+// signalBarPollerReload wakes the poller without blocking. The channel is
156+// buffered to one, so a pending signal already conveys "config changed".
157+func (d *Daemon) signalBarPollerReload() {
158+ if d == nil || d.barScripts == nil || d.barScripts.reload == nil {
159+ return
160+ }
161+ select {
162+ case d.barScripts.reload <- struct{}{}:
163+ default:
164+ }
124165 }
125166
126167 func (d *Daemon) barScriptPoller(ctx context.Context) {
127168 timer := d.clock.NewTimer(d.barScriptInterval())
128- defer timer.Stop()
169+ defer func() { timer.Stop() }()
129170 for {
130171 select {
131172 case <-ctx.Done():
132173 return
174+ case <-d.barScripts.reload:
175+ timer.Stop()
176+ timer = d.clock.NewTimer(d.barScriptInterval())
133177 case now := <-timer.C():
134178 for _, sess := range d.sessionsSnapshot() {
135179 d.refreshBarScriptsIfDue(sess, now, false)
136180 }
137- timer.Reset(d.barScriptInterval())
181+ timer.Stop()
182+ timer = d.clock.NewTimer(d.barScriptInterval())
138183 }
139184 }
140185 }
141186
187+// refreshBarScriptsAllSessions forces a bar-script run for every live session.
188+// Called after a config change so a new command takes effect immediately rather
189+// than leaving the anchor blank until the poller's next tick.
190+func (d *Daemon) refreshBarScriptsAllSessions() {
191+ if d == nil || d.barScripts == nil || d.clock == nil {
192+ return
193+ }
194+ now := d.clock.Now()
195+ for _, sess := range d.sessionsSnapshot() {
196+ d.refreshBarScriptsIfDue(sess, now, true)
197+ }
198+}
199+
142200 func (d *Daemon) sessionsSnapshot() []*session {
143201 d.mu.Lock()
144202 defer d.mu.Unlock()
@@ -160,16 +218,44 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
160218 delete(d.barScripts.lastContext, id)
161219 delete(d.barScripts.running, id)
162220 delete(d.barScripts.pending, id)
221+ for _, anchor := range barAnchors {
222+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
223+ }
224+}
225+
226+// shouldLogBarFailure reports whether this failure differs from the last one
227+// logged for the same session and anchor, so a persistently broken script
228+// warns once instead of every refresh interval.
229+func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool {
230+ d.barScripts.mu.Lock()
231+ defer d.barScripts.mu.Unlock()
232+ d.barScripts.initLocked()
233+ key := barScriptFailureKey{id: id, anchor: anchor}
234+ if d.barScripts.lastFailure[key] == signature {
235+ return false
236+ }
237+ d.barScripts.lastFailure[key] = signature
238+ return true
239+}
240+
241+func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) {
242+ d.barScripts.mu.Lock()
243+ defer d.barScripts.mu.Unlock()
244+ d.barScripts.initLocked()
245+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
163246 }
164247
165248 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
166249 if d == nil || d.barScripts == nil || sess == nil {
167250 return false
168251 }
169- baseCtx, ok := d.collectBarScriptContext(sess, "")
252+ baseCtx, env, ok := d.collectBarScriptContext(sess, "")
170253 if !ok {
171254 return false
172255 }
256+ if len(env) == 0 {
257+ env = d.baseEnv
258+ }
173259 d.barScripts.mu.Lock()
174260 d.barScripts.initLocked()
175261 if d.barScripts.lastContext[sess.id] != baseCtx {
@@ -213,7 +299,7 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
213299 runner := d.barScripts.runner
214300 version := d.barScripts.version
215301 d.barScripts.mu.Unlock()
216- go d.runBarScripts(sess, runner, cfg, baseCtx, version)
302+ go d.runBarScripts(sess, runner, cfg, baseCtx, env, version)
217303 return true
218304 }
219305
@@ -247,16 +333,16 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati
247333 }()
248334 }
249335
250-func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {
336+func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) {
251337 if runner == nil {
252- runner = barScriptRunner{baseEnv: d.baseEnv}
338+ runner = barScriptRunner{}
253339 }
254340 ctx := sess.ctx
255341 if ctx == nil {
256342 ctx = context.Background()
257343 }
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)
344+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess)
345+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess)
260346 d.barScripts.mu.Lock()
261347 d.barScripts.initLocked()
262348 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
@@ -287,21 +373,46 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
287373 }
288374 }
289375
290-func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
376+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
291377 if command == "" {
292378 return "", true
293379 }
294380 base.Anchor = anchor
295- out, err := runner.run(ctx, command, base)
381+ out, err := runner.run(ctx, command, env, base)
296382 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- }
383+ d.logBarScriptFailure(sess, base.Session, anchor, command, env, err)
300384 return "", false
301385 }
386+ d.clearBarFailure(sess.id, anchor)
302387 return out, true
303388 }
304389
390+// logBarScriptFailure logs a bar script failure. sessionName must come from a
391+// synchronized read of sess.name (e.g. base.Session, captured under sess.mu
392+// in collectBarScriptContext) since sess.name is mutable via session rename.
393+func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) {
394+ if d.log == nil {
395+ return
396+ }
397+ if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
398+ return
399+ }
400+ attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
401+ var scriptErr *barScriptError
402+ if errors.As(err, &scriptErr) {
403+ attrs = append(attrs, "exit_code", scriptErr.exitCode)
404+ if scriptErr.stderr != "" {
405+ attrs = append(attrs, "stderr", scriptErr.stderr)
406+ }
407+ if scriptErr.exitCode == 127 {
408+ attrs = append(attrs, "hint",
409+ "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
410+ "PATH", pathFromEnv(env))
411+ }
412+ }
413+ d.log.Warn("bar script failed; keeping last good output", attrs...)
414+}
415+
305416 func (d *Daemon) pokeSessionRender(sess *session) {
306417 if sess == nil {
307418 return
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "time"7 "time"
7 8
@@ -9,9 +10,20 @@ import (
9 )10 )
10 11
11 type barScriptExecutor interface {12 type barScriptExecutor interface {
12- run(context.Context, string, barScriptContext) (string, error)13+ run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error)
13 }14 }
14 15
16+// Anchor names, exported to bar scripts as VEV_ANCHOR and documented in
17+// docs/configuration.md. barAnchors must list every anchor so cleanup in
18+// clearBarScriptsForSession stays in sync with the anchors run in
19+// runBarScripts.
20+const (
21+ barAnchorTopRight = "top-right"
22+ barAnchorBottomRight = "bottom-right"
23+)
24+
25+var barAnchors = []string{barAnchorTopRight, barAnchorBottomRight}
26+
15 type barScriptConfig struct {27 type barScriptConfig struct {
16 topRight string28 topRight string
17 bottomRight string29 bottomRight string
@@ -23,6 +35,11 @@ type barScriptOutputs struct {
23 bottomRight string35 bottomRight string
24 }36 }
25 37
38+type barScriptFailureKey struct {
39+ id domain.SessionID
40+ anchor string
41+}
42+
26 type barScriptState struct {43 type barScriptState struct {
27 mu sync.Mutex44 mu sync.Mutex
28 cfg barScriptConfig45 cfg barScriptConfig
@@ -32,7 +49,12 @@ type barScriptState struct {
32 lastContext map[domain.SessionID]barScriptContext49 lastContext map[domain.SessionID]barScriptContext
33 running map[domain.SessionID]bool50 running map[domain.SessionID]bool
34 pending map[domain.SessionID]bool51 pending map[domain.SessionID]bool
52+ lastFailure map[barScriptFailureKey]string
35 version uint6453 version uint64
54+
55+ // reload wakes barScriptPoller so an interval change takes effect without
56+ // waiting out the timer armed under the previous interval.
57+ reload chan struct{}
36 }58 }
37 59
38 func effectiveBarInterval(d time.Duration) time.Duration {60 func effectiveBarInterval(d time.Duration) time.Duration {
@@ -65,6 +87,9 @@ func (s *barScriptState) initLocked() {
65 if s.pending == nil {87 if s.pending == nil {
66 s.pending = make(map[domain.SessionID]bool)88 s.pending = make(map[domain.SessionID]bool)
67 }89 }
90+ if s.lastFailure == nil {
91+ s.lastFailure = make(map[barScriptFailureKey]string)
92+ }
68 }93 }
69 94
70 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {95 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
@@ -86,13 +111,17 @@ func (d *Daemon) barScriptInterval() time.Duration {
86 return effectiveBarInterval(d.barScripts.cfg.interval)111 return effectiveBarInterval(d.barScripts.cfg.interval)
87 }112 }
88 113
89-func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) {114+func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, []string, bool) {
90 ctx := barScriptContext{Anchor: anchor}115 ctx := barScriptContext{Anchor: anchor}
91 if sess == nil {116 if sess == nil {
92- return ctx, false117+ return ctx, nil, false
93 }118 }
94 sess.mu.Lock()119 sess.mu.Lock()
95 ctx.Session = sess.name120 ctx.Session = sess.name
121+ // No copy needed: writers always replace sess.env with a whole fresh
122+ // slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123+ // backing array in place, so capturing the header here is safe.
124+ env := sess.env
96 active := sess.active125 active := sess.active
97 var tb *tab126 var tb *tab
98 if active >= 0 && active < len(sess.tabs) {127 if active >= 0 && active < len(sess.tabs) {
@@ -102,7 +131,7 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
102 ctx.PaneCWD = sess.cwd131 ctx.PaneCWD = sess.cwd
103 sess.mu.Unlock()132 sess.mu.Unlock()
104 if ac == nil || tb == nil {133 if ac == nil || tb == nil {
105- return ctx, false134+ return ctx, env, false
106 }135 }
107 tb.mu.Lock()136 tb.mu.Lock()
108 ctx.Tab = tb.stableID137 ctx.Tab = tb.stableID
@@ -120,25 +149,54 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
120 ctx.PaneCWD = cwd149 ctx.PaneCWD = cwd
121 }150 }
122 }151 }
123- return ctx, true152+ return ctx, env, true
153+}
154+
155+// signalBarPollerReload wakes the poller without blocking. The channel is
156+// buffered to one, so a pending signal already conveys "config changed".
157+func (d *Daemon) signalBarPollerReload() {
158+ if d == nil || d.barScripts == nil || d.barScripts.reload == nil {
159+ return
160+ }
161+ select {
162+ case d.barScripts.reload <- struct{}{}:
163+ default:
164+ }
124 }165 }
125 166
126 func (d *Daemon) barScriptPoller(ctx context.Context) {167 func (d *Daemon) barScriptPoller(ctx context.Context) {
127 timer := d.clock.NewTimer(d.barScriptInterval())168 timer := d.clock.NewTimer(d.barScriptInterval())
128- defer timer.Stop()169+ defer func() { timer.Stop() }()
129 for {170 for {
130 select {171 select {
131 case <-ctx.Done():172 case <-ctx.Done():
132 return173 return
174+ case <-d.barScripts.reload:
175+ timer.Stop()
176+ timer = d.clock.NewTimer(d.barScriptInterval())
133 case now := <-timer.C():177 case now := <-timer.C():
134 for _, sess := range d.sessionsSnapshot() {178 for _, sess := range d.sessionsSnapshot() {
135 d.refreshBarScriptsIfDue(sess, now, false)179 d.refreshBarScriptsIfDue(sess, now, false)
136 }180 }
137- timer.Reset(d.barScriptInterval())181+ timer.Stop()
182+ timer = d.clock.NewTimer(d.barScriptInterval())
138 }183 }
139 }184 }
140 }185 }
141 186
187+// refreshBarScriptsAllSessions forces a bar-script run for every live session.
188+// Called after a config change so a new command takes effect immediately rather
189+// than leaving the anchor blank until the poller's next tick.
190+func (d *Daemon) refreshBarScriptsAllSessions() {
191+ if d == nil || d.barScripts == nil || d.clock == nil {
192+ return
193+ }
194+ now := d.clock.Now()
195+ for _, sess := range d.sessionsSnapshot() {
196+ d.refreshBarScriptsIfDue(sess, now, true)
197+ }
198+}
199+
142 func (d *Daemon) sessionsSnapshot() []*session {200 func (d *Daemon) sessionsSnapshot() []*session {
143 d.mu.Lock()201 d.mu.Lock()
144 defer d.mu.Unlock()202 defer d.mu.Unlock()
@@ -160,16 +218,44 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
160 delete(d.barScripts.lastContext, id)218 delete(d.barScripts.lastContext, id)
161 delete(d.barScripts.running, id)219 delete(d.barScripts.running, id)
162 delete(d.barScripts.pending, id)220 delete(d.barScripts.pending, id)
221+ for _, anchor := range barAnchors {
222+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
223+ }
224+}
225+
226+// shouldLogBarFailure reports whether this failure differs from the last one
227+// logged for the same session and anchor, so a persistently broken script
228+// warns once instead of every refresh interval.
229+func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool {
230+ d.barScripts.mu.Lock()
231+ defer d.barScripts.mu.Unlock()
232+ d.barScripts.initLocked()
233+ key := barScriptFailureKey{id: id, anchor: anchor}
234+ if d.barScripts.lastFailure[key] == signature {
235+ return false
236+ }
237+ d.barScripts.lastFailure[key] = signature
238+ return true
239+}
240+
241+func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) {
242+ d.barScripts.mu.Lock()
243+ defer d.barScripts.mu.Unlock()
244+ d.barScripts.initLocked()
245+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
163 }246 }
164 247
165 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {248 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
166 if d == nil || d.barScripts == nil || sess == nil {249 if d == nil || d.barScripts == nil || sess == nil {
167 return false250 return false
168 }251 }
169- baseCtx, ok := d.collectBarScriptContext(sess, "")252+ baseCtx, env, ok := d.collectBarScriptContext(sess, "")
170 if !ok {253 if !ok {
171 return false254 return false
172 }255 }
256+ if len(env) == 0 {
257+ env = d.baseEnv
258+ }
173 d.barScripts.mu.Lock()259 d.barScripts.mu.Lock()
174 d.barScripts.initLocked()260 d.barScripts.initLocked()
175 if d.barScripts.lastContext[sess.id] != baseCtx {261 if d.barScripts.lastContext[sess.id] != baseCtx {
@@ -213,7 +299,7 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
213 runner := d.barScripts.runner299 runner := d.barScripts.runner
214 version := d.barScripts.version300 version := d.barScripts.version
215 d.barScripts.mu.Unlock()301 d.barScripts.mu.Unlock()
216- go d.runBarScripts(sess, runner, cfg, baseCtx, version)302+ go d.runBarScripts(sess, runner, cfg, baseCtx, env, version)
217 return true303 return true
218 }304 }
219 305
@@ -247,16 +333,16 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati
247 }()333 }()
248 }334 }
249 335
250-func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {336+func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) {
251 if runner == nil {337 if runner == nil {
252- runner = barScriptRunner{baseEnv: d.baseEnv}338+ runner = barScriptRunner{}
253 }339 }
254 ctx := sess.ctx340 ctx := sess.ctx
255 if ctx == nil {341 if ctx == nil {
256 ctx = context.Background()342 ctx = context.Background()
257 }343 }
258- top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name)344+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess)
259- bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name)345+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess)
260 d.barScripts.mu.Lock()346 d.barScripts.mu.Lock()
261 d.barScripts.initLocked()347 d.barScripts.initLocked()
262 if !d.barScripts.running[sess.id] || d.barScripts.version != version {348 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
@@ -287,21 +373,46 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
287 }373 }
288 }374 }
289 375
290-func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {376+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
291 if command == "" {377 if command == "" {
292 return "", true378 return "", true
293 }379 }
294 base.Anchor = anchor380 base.Anchor = anchor
295- out, err := runner.run(ctx, command, base)381+ out, err := runner.run(ctx, command, env, base)
296 if err != nil {382 if err != nil {
297- if d.log != nil {383+ d.logBarScriptFailure(sess, base.Session, anchor, command, env, err)
298- d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
299- }
300 return "", false384 return "", false
301 }385 }
386+ d.clearBarFailure(sess.id, anchor)
302 return out, true387 return out, true
303 }388 }
304 389
390+// logBarScriptFailure logs a bar script failure. sessionName must come from a
391+// synchronized read of sess.name (e.g. base.Session, captured under sess.mu
392+// in collectBarScriptContext) since sess.name is mutable via session rename.
393+func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) {
394+ if d.log == nil {
395+ return
396+ }
397+ if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
398+ return
399+ }
400+ attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
401+ var scriptErr *barScriptError
402+ if errors.As(err, &scriptErr) {
403+ attrs = append(attrs, "exit_code", scriptErr.exitCode)
404+ if scriptErr.stderr != "" {
405+ attrs = append(attrs, "stderr", scriptErr.stderr)
406+ }
407+ if scriptErr.exitCode == 127 {
408+ attrs = append(attrs, "hint",
409+ "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
410+ "PATH", pathFromEnv(env))
411+ }
412+ }
413+ d.log.Warn("bar script failed; keeping last good output", attrs...)
414+}
415+
305 func (d *Daemon) pokeSessionRender(sess *session) {416 func (d *Daemon) pokeSessionRender(sess *session) {
306 if sess == nil {417 if sess == nil {
307 return418 return
M internal/usecase/daemon/bar_refresh_test.go +303 -5
@@ -12,7 +12,9 @@ import (
1212
1313 "github.com/bnema/vev/internal/domain"
1414 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1516 "github.com/bnema/vev/internal/usecase/layout"
17+ "github.com/stretchr/testify/mock"
1618 "github.com/stretchr/testify/require"
1719 )
1820
@@ -23,7 +25,7 @@ type fakeBarRunner struct {
2325 errs []error
2426 }
2527
26-func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) {
28+func (r *fakeBarRunner) run(_ context.Context, _ string, _ []string, ctx barScriptContext) (string, error) {
2729 r.mu.Lock()
2830 defer r.mu.Unlock()
2931 r.calls = append(r.calls, ctx)
@@ -48,14 +50,14 @@ func newBlockingBarRunner(outs []string) *blockingBarRunner {
4850 return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}
4951 }
5052
51-func (r *blockingBarRunner) run(ctx context.Context, command string, barCtx barScriptContext) (string, error) {
53+func (r *blockingBarRunner) run(ctx context.Context, command string, env []string, barCtx barScriptContext) (string, error) {
5254 r.once.Do(func() { close(r.entered) })
5355 select {
5456 case <-r.release:
5557 case <-ctx.Done():
5658 return "", ctx.Err()
5759 }
58- return r.fakeBarRunner.run(ctx, command, barCtx)
60+ return r.fakeBarRunner.run(ctx, command, env, barCtx)
5961 }
6062
6163 func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
@@ -213,6 +215,37 @@ func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
213215 require.Equal(t, "bottom-good", state.bottomRight)
214216 }
215217
218+func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
219+ r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
220+ d := newBarRefreshTestDaemon(r, 60*time.Second)
221+ sess := newBarRefreshTestSession()
222+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
223+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
224+
225+ // Verifies refreshBarScriptsAllSessions iterates every live session and
226+ // dispatches a run for each. This does not exercise force specifically:
227+ // lastRefresh is zero on a fresh session, so refreshBarScriptsIfDue skips
228+ // the interval check regardless of force, and a non-forcing call would
229+ // pass this test too.
230+ d.refreshBarScriptsAllSessions()
231+ waitBarRefreshIdle(t, d)
232+
233+ require.Len(t, r.calls, 2, "should force a run for every live session")
234+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
235+}
236+
237+func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) {
238+ r := &fakeBarRunner{}
239+ d := newBarRefreshTestDaemon(r, time.Second)
240+
241+ before := d.barScripts.version
242+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
243+ require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate")
244+
245+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}})
246+ require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate")
247+}
248+
216249 func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
217250 var logs bytes.Buffer
218251 r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
@@ -243,7 +276,7 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
243276 d.barScripts.mu.Unlock()
244277
245278 d.clearBarScriptsForSession(sess.id)
246- d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0)
279+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 0)
247280
248281 state := d.barStateFor(sess, "")
249282 require.Empty(t, state.topRight)
@@ -272,6 +305,87 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
272305 }, time.Second, time.Millisecond)
273306 }
274307
308+// TestBarScriptFailureLogsOnceUntilSignatureChanges covers the full
309+// shouldLogBarFailure/clearBarFailure contract for a single anchor
310+// (top-right; bottom-right always succeeds in this test so it never
311+// contributes a log line):
312+//
313+// 1. an identical repeated failure does not log a second time,
314+// 2. a different failure for the same session+anchor logs again, and
315+// 3. a successful run clears the recorded signature, so a subsequent
316+// identical failure logs again afterward.
317+//
318+// Each refresh consumes two fakeBarRunner call-count entries (top-right then
319+// bottom-right), so the stages are order-dependent and asserted sequentially
320+// rather than table-driven.
321+func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
322+ var buf bytes.Buffer
323+ var mu sync.Mutex
324+ logger := slog.New(slog.NewTextHandler(&syncWriter{w: &buf, mu: &mu}, nil))
325+
326+ notFound := &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")}
327+ permissionDenied := &barScriptError{exitCode: 126, stderr: "sh: vev-bar-top-right: permission denied", err: errors.New("exit status 126")}
328+
329+ r := &fakeBarRunner{errs: []error{
330+ notFound, nil, // refresh 1: top fails
331+ notFound, nil, // refresh 2: top fails identically -> must not log again
332+ permissionDenied, nil, // refresh 3: top fails differently -> must log again
333+ nil, nil, // refresh 4: top succeeds -> clears recorded signature
334+ notFound, nil, // refresh 5: top fails with the original signature -> must log (cleared)
335+ }}
336+ d := newBarRefreshTestDaemon(r, time.Second)
337+ d.log = logger
338+ d.baseEnv = []string{"PATH=/usr/bin"}
339+ sess := newBarRefreshTestSession()
340+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
341+
342+ readBuf := func() string {
343+ mu.Lock()
344+ defer mu.Unlock()
345+ return buf.String()
346+ }
347+
348+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
349+ waitBarRefreshIdle(t, d)
350+ out := readBuf()
351+ require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "first failure should log")
352+ require.Contains(t, out, "not found")
353+ require.Contains(t, out, "PATH=/usr/bin")
354+
355+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
356+ waitBarRefreshIdle(t, d)
357+ out = readBuf()
358+ require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "identical repeated failure should not log again")
359+
360+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(20, 0), true))
361+ waitBarRefreshIdle(t, d)
362+ out = readBuf()
363+ require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a different failure for the same anchor should log again")
364+ require.Contains(t, out, "permission denied")
365+
366+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(30, 0), true))
367+ waitBarRefreshIdle(t, d)
368+ out = readBuf()
369+ require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a successful run must not log and must clear the recorded signature")
370+
371+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(40, 0), true))
372+ waitBarRefreshIdle(t, d)
373+ out = readBuf()
374+ require.Equal(t, 3, strings.Count(out, "anchor=top-right"),
375+ "an identical failure after a successful run should log again since the recorded signature was cleared")
376+}
377+
378+type syncWriter struct {
379+ w *bytes.Buffer
380+ mu *sync.Mutex
381+}
382+
383+func (s *syncWriter) Write(p []byte) (int, error) {
384+ s.mu.Lock()
385+ defer s.mu.Unlock()
386+ return s.w.Write(p)
387+}
388+
275389 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
276390 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
277391 d := newBarRefreshTestDaemon(r, time.Second)
@@ -281,7 +395,7 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
281395 d.barScripts.version = 2
282396 d.barScripts.mu.Unlock()
283397
284- d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1)
398+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 1)
285399
286400 state := d.barStateFor(sess, "")
287401 require.Empty(t, state.topRight)
@@ -325,6 +439,130 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
325439 require.Equal(t, "bottom-b", stateB.bottomRight)
326440 }
327441
442+// pollerMockClock and pollerMockTimer wrap the generated portsmocks.MockClock
443+// and portsmocks.MockTimer (see internal/ports/mocks/clock_mock.go), following
444+// the coordinatorMockClock pattern in render_coordinator_test.go: the mocks
445+// supply the ports.Clock/ports.Timer implementations, while a small
446+// hand-owned struct retains the real channel and requested duration for each
447+// timer so the test goroutine can fire ticks and assert on re-arming.
448+type pollerMockClock struct {
449+ clock *portsmocks.MockClock
450+ timers chan *pollerMockTimer
451+}
452+
453+type pollerMockTimer struct {
454+ mock *portsmocks.MockTimer
455+ ch chan time.Time
456+ duration time.Duration
457+}
458+
459+func newPollerMockClock(t *testing.T, capacity int) *pollerMockClock {
460+ t.Helper()
461+ clk := &pollerMockClock{
462+ clock: portsmocks.NewMockClock(t),
463+ timers: make(chan *pollerMockTimer, capacity),
464+ }
465+ clk.clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
466+ clk.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
467+ timer := &pollerMockTimer{
468+ mock: portsmocks.NewMockTimer(t),
469+ duration: d,
470+ ch: make(chan time.Time, 1),
471+ }
472+ // Stop is called 0, 1, or 2 times per timer depending on whether it is
473+ // replaced mid-loop or torn down by the poller's deferred Stop on
474+ // exit, so its expectation is unconstrained.
475+ timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
476+ timer.mock.EXPECT().Stop().Maybe().Return(true)
477+ clk.timers <- timer
478+ return timer.mock
479+ }).Maybe()
480+ return clk
481+}
482+
483+// TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller
484+// directly (not through Serve) with a mock clock, covering the three
485+// behaviors the Task 5 rewrite touched but left untested:
486+//
487+// 1. after a tick fires, the loop arms a NEW timer instead of blocking
488+// forever (the regression that would silently stop bar refreshes),
489+// 2. a reload signal re-arms the timer at the newly configured interval
490+// rather than the one the poller started with, and
491+// 3. cancelling the context makes the goroutine return instead of leaking.
492+func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
493+ clk := newPollerMockClock(t, 8)
494+ d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)
495+ d.clock = clk.clock
496+ d.barScripts.reload = make(chan struct{}, 1)
497+
498+ ctx, cancel := context.WithCancel(context.Background())
499+ done := make(chan struct{})
500+ go func() {
501+ d.barScriptPoller(ctx)
502+ close(done)
503+ }()
504+
505+ // Stage 1: the poller arms its first timer at the configured interval.
506+ var timer0 *pollerMockTimer
507+ select {
508+ case timer0 = <-clk.timers:
509+ case <-time.After(time.Second):
510+ t.Fatal("timed out waiting for the poller's initial timer")
511+ }
512+ require.Equal(t, 2*time.Second, timer0.duration)
513+
514+ // Stage 2: firing that timer processes the tick and re-arms a new one.
515+ timer0.ch <- time.Unix(0, 0)
516+ var timer1 *pollerMockTimer
517+ select {
518+ case timer1 = <-clk.timers:
519+ case <-time.After(time.Second):
520+ t.Fatal("timed out waiting for the poller to re-arm after a tick")
521+ }
522+ require.Equal(t, 2*time.Second, timer1.duration, "tick re-arm should keep the same interval when config is unchanged")
523+
524+ // Stage 3: changing the interval and signaling reload re-arms at the NEW
525+ // interval instead of waiting out the timer armed under the old one.
526+ d.barScripts.mu.Lock()
527+ d.barScripts.cfg.interval = 7 * time.Second
528+ d.barScripts.mu.Unlock()
529+ d.barScripts.reload <- struct{}{}
530+ var timer2 *pollerMockTimer
531+ select {
532+ case timer2 = <-clk.timers:
533+ case <-time.After(time.Second):
534+ t.Fatal("timed out waiting for the poller to re-arm after reload")
535+ }
536+ require.Equal(t, 7*time.Second, timer2.duration, "reload should re-arm at the newly configured interval")
537+
538+ // Stage 4: cancelling the context exits the goroutine instead of leaking it.
539+ cancel()
540+ select {
541+ case <-done:
542+ case <-time.After(time.Second):
543+ t.Fatal("barScriptPoller did not exit after context cancellation")
544+ }
545+}
546+
547+func TestApplyConfigSignalsPollerReload(t *testing.T) {
548+ r := &fakeBarRunner{}
549+ d := newBarRefreshTestDaemon(r, 60*time.Second)
550+ d.barScripts.reload = make(chan struct{}, 1)
551+
552+ cfg := domain.Defaults()
553+ cfg.Bar.TopRight = "t"
554+ cfg.Bar.BottomRight = "b"
555+ cfg.Bar.Interval = time.Second
556+ d.ApplyConfig(cfg)
557+
558+ select {
559+ case <-d.barScripts.reload:
560+ default:
561+ t.Fatal("ApplyConfig should signal the poller to re-arm its timer")
562+ }
563+ require.Equal(t, time.Second, d.barScriptInterval())
564+}
565+
328566 func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
329567 d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
330568 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
@@ -360,3 +598,63 @@ func waitBarRefreshIdle(t *testing.T, d *Daemon) {
360598 return len(d.barScripts.running) == 0
361599 }, time.Second, time.Millisecond)
362600 }
601+
602+type envRecordingRunner struct {
603+ mu sync.Mutex
604+ envs [][]string
605+}
606+
607+func (r *envRecordingRunner) run(_ context.Context, _ string, env []string, _ barScriptContext) (string, error) {
608+ r.mu.Lock()
609+ defer r.mu.Unlock()
610+ r.envs = append(r.envs, append([]string(nil), env...))
611+ return "out", nil
612+}
613+
614+func (r *envRecordingRunner) captured() [][]string {
615+ r.mu.Lock()
616+ defer r.mu.Unlock()
617+ return append([][]string(nil), r.envs...)
618+}
619+
620+func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
621+ tests := []struct {
622+ name string
623+ sessEnv []string
624+ baseEnv []string
625+ wantPath string
626+ }{
627+ {
628+ name: "session env wins over stale daemon env",
629+ sessEnv: []string{"PATH=/home/u/.local/bin:/usr/bin"},
630+ baseEnv: []string{"PATH=/usr/bin"},
631+ wantPath: "PATH=/home/u/.local/bin:/usr/bin",
632+ },
633+ {
634+ name: "falls back to daemon env when session env empty",
635+ sessEnv: nil,
636+ baseEnv: []string{"PATH=/usr/bin"},
637+ wantPath: "PATH=/usr/bin",
638+ },
639+ }
640+
641+ for _, tc := range tests {
642+ t.Run(tc.name, func(t *testing.T) {
643+ r := &envRecordingRunner{}
644+ d := newBarRefreshTestDaemon(r, time.Second)
645+ d.baseEnv = tc.baseEnv
646+ sess := newBarRefreshTestSession()
647+ sess.env = tc.sessEnv
648+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
649+
650+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
651+ waitBarRefreshIdle(t, d)
652+
653+ envs := r.captured()
654+ require.Len(t, envs, 2, "one call per anchor")
655+ for _, env := range envs {
656+ require.Contains(t, env, tc.wantPath)
657+ }
658+ })
659+ }
660+}
@@ -12,7 +12,9 @@ import (
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
17+ "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
17 )19 )
18 20
@@ -23,7 +25,7 @@ type fakeBarRunner struct {
23 errs []error25 errs []error
24 }26 }
25 27
26-func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) {28+func (r *fakeBarRunner) run(_ context.Context, _ string, _ []string, ctx barScriptContext) (string, error) {
27 r.mu.Lock()29 r.mu.Lock()
28 defer r.mu.Unlock()30 defer r.mu.Unlock()
29 r.calls = append(r.calls, ctx)31 r.calls = append(r.calls, ctx)
@@ -48,14 +50,14 @@ func newBlockingBarRunner(outs []string) *blockingBarRunner {
48 return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}50 return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}
49 }51 }
50 52
51-func (r *blockingBarRunner) run(ctx context.Context, command string, barCtx barScriptContext) (string, error) {53+func (r *blockingBarRunner) run(ctx context.Context, command string, env []string, barCtx barScriptContext) (string, error) {
52 r.once.Do(func() { close(r.entered) })54 r.once.Do(func() { close(r.entered) })
53 select {55 select {
54 case <-r.release:56 case <-r.release:
55 case <-ctx.Done():57 case <-ctx.Done():
56 return "", ctx.Err()58 return "", ctx.Err()
57 }59 }
58- return r.fakeBarRunner.run(ctx, command, barCtx)60+ return r.fakeBarRunner.run(ctx, command, env, barCtx)
59 }61 }
60 62
61 func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {63 func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
@@ -213,6 +215,37 @@ func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
213 require.Equal(t, "bottom-good", state.bottomRight)215 require.Equal(t, "bottom-good", state.bottomRight)
214 }216 }
215 217
218+func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
219+ r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
220+ d := newBarRefreshTestDaemon(r, 60*time.Second)
221+ sess := newBarRefreshTestSession()
222+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
223+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
224+
225+ // Verifies refreshBarScriptsAllSessions iterates every live session and
226+ // dispatches a run for each. This does not exercise force specifically:
227+ // lastRefresh is zero on a fresh session, so refreshBarScriptsIfDue skips
228+ // the interval check regardless of force, and a non-forcing call would
229+ // pass this test too.
230+ d.refreshBarScriptsAllSessions()
231+ waitBarRefreshIdle(t, d)
232+
233+ require.Len(t, r.calls, 2, "should force a run for every live session")
234+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
235+}
236+
237+func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) {
238+ r := &fakeBarRunner{}
239+ d := newBarRefreshTestDaemon(r, time.Second)
240+
241+ before := d.barScripts.version
242+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
243+ require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate")
244+
245+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}})
246+ require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate")
247+}
248+
216 func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {249 func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
217 var logs bytes.Buffer250 var logs bytes.Buffer
218 r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}251 r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
@@ -243,7 +276,7 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
243 d.barScripts.mu.Unlock()276 d.barScripts.mu.Unlock()
244 277
245 d.clearBarScriptsForSession(sess.id)278 d.clearBarScriptsForSession(sess.id)
246- d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0)279+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 0)
247 280
248 state := d.barStateFor(sess, "")281 state := d.barStateFor(sess, "")
249 require.Empty(t, state.topRight)282 require.Empty(t, state.topRight)
@@ -272,6 +305,87 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
272 }, time.Second, time.Millisecond)305 }, time.Second, time.Millisecond)
273 }306 }
274 307
308+// TestBarScriptFailureLogsOnceUntilSignatureChanges covers the full
309+// shouldLogBarFailure/clearBarFailure contract for a single anchor
310+// (top-right; bottom-right always succeeds in this test so it never
311+// contributes a log line):
312+//
313+// 1. an identical repeated failure does not log a second time,
314+// 2. a different failure for the same session+anchor logs again, and
315+// 3. a successful run clears the recorded signature, so a subsequent
316+// identical failure logs again afterward.
317+//
318+// Each refresh consumes two fakeBarRunner call-count entries (top-right then
319+// bottom-right), so the stages are order-dependent and asserted sequentially
320+// rather than table-driven.
321+func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
322+ var buf bytes.Buffer
323+ var mu sync.Mutex
324+ logger := slog.New(slog.NewTextHandler(&syncWriter{w: &buf, mu: &mu}, nil))
325+
326+ notFound := &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")}
327+ permissionDenied := &barScriptError{exitCode: 126, stderr: "sh: vev-bar-top-right: permission denied", err: errors.New("exit status 126")}
328+
329+ r := &fakeBarRunner{errs: []error{
330+ notFound, nil, // refresh 1: top fails
331+ notFound, nil, // refresh 2: top fails identically -> must not log again
332+ permissionDenied, nil, // refresh 3: top fails differently -> must log again
333+ nil, nil, // refresh 4: top succeeds -> clears recorded signature
334+ notFound, nil, // refresh 5: top fails with the original signature -> must log (cleared)
335+ }}
336+ d := newBarRefreshTestDaemon(r, time.Second)
337+ d.log = logger
338+ d.baseEnv = []string{"PATH=/usr/bin"}
339+ sess := newBarRefreshTestSession()
340+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
341+
342+ readBuf := func() string {
343+ mu.Lock()
344+ defer mu.Unlock()
345+ return buf.String()
346+ }
347+
348+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
349+ waitBarRefreshIdle(t, d)
350+ out := readBuf()
351+ require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "first failure should log")
352+ require.Contains(t, out, "not found")
353+ require.Contains(t, out, "PATH=/usr/bin")
354+
355+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
356+ waitBarRefreshIdle(t, d)
357+ out = readBuf()
358+ require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "identical repeated failure should not log again")
359+
360+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(20, 0), true))
361+ waitBarRefreshIdle(t, d)
362+ out = readBuf()
363+ require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a different failure for the same anchor should log again")
364+ require.Contains(t, out, "permission denied")
365+
366+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(30, 0), true))
367+ waitBarRefreshIdle(t, d)
368+ out = readBuf()
369+ require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a successful run must not log and must clear the recorded signature")
370+
371+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(40, 0), true))
372+ waitBarRefreshIdle(t, d)
373+ out = readBuf()
374+ require.Equal(t, 3, strings.Count(out, "anchor=top-right"),
375+ "an identical failure after a successful run should log again since the recorded signature was cleared")
376+}
377+
378+type syncWriter struct {
379+ w *bytes.Buffer
380+ mu *sync.Mutex
381+}
382+
383+func (s *syncWriter) Write(p []byte) (int, error) {
384+ s.mu.Lock()
385+ defer s.mu.Unlock()
386+ return s.w.Write(p)
387+}
388+
275 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {389 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
276 r := &fakeBarRunner{outs: []string{"top", "bottom"}}390 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
277 d := newBarRefreshTestDaemon(r, time.Second)391 d := newBarRefreshTestDaemon(r, time.Second)
@@ -281,7 +395,7 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
281 d.barScripts.version = 2395 d.barScripts.version = 2
282 d.barScripts.mu.Unlock()396 d.barScripts.mu.Unlock()
283 397
284- d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1)398+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 1)
285 399
286 state := d.barStateFor(sess, "")400 state := d.barStateFor(sess, "")
287 require.Empty(t, state.topRight)401 require.Empty(t, state.topRight)
@@ -325,6 +439,130 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
325 require.Equal(t, "bottom-b", stateB.bottomRight)439 require.Equal(t, "bottom-b", stateB.bottomRight)
326 }440 }
327 441
442+// pollerMockClock and pollerMockTimer wrap the generated portsmocks.MockClock
443+// and portsmocks.MockTimer (see internal/ports/mocks/clock_mock.go), following
444+// the coordinatorMockClock pattern in render_coordinator_test.go: the mocks
445+// supply the ports.Clock/ports.Timer implementations, while a small
446+// hand-owned struct retains the real channel and requested duration for each
447+// timer so the test goroutine can fire ticks and assert on re-arming.
448+type pollerMockClock struct {
449+ clock *portsmocks.MockClock
450+ timers chan *pollerMockTimer
451+}
452+
453+type pollerMockTimer struct {
454+ mock *portsmocks.MockTimer
455+ ch chan time.Time
456+ duration time.Duration
457+}
458+
459+func newPollerMockClock(t *testing.T, capacity int) *pollerMockClock {
460+ t.Helper()
461+ clk := &pollerMockClock{
462+ clock: portsmocks.NewMockClock(t),
463+ timers: make(chan *pollerMockTimer, capacity),
464+ }
465+ clk.clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
466+ clk.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
467+ timer := &pollerMockTimer{
468+ mock: portsmocks.NewMockTimer(t),
469+ duration: d,
470+ ch: make(chan time.Time, 1),
471+ }
472+ // Stop is called 0, 1, or 2 times per timer depending on whether it is
473+ // replaced mid-loop or torn down by the poller's deferred Stop on
474+ // exit, so its expectation is unconstrained.
475+ timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
476+ timer.mock.EXPECT().Stop().Maybe().Return(true)
477+ clk.timers <- timer
478+ return timer.mock
479+ }).Maybe()
480+ return clk
481+}
482+
483+// TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller
484+// directly (not through Serve) with a mock clock, covering the three
485+// behaviors the Task 5 rewrite touched but left untested:
486+//
487+// 1. after a tick fires, the loop arms a NEW timer instead of blocking
488+// forever (the regression that would silently stop bar refreshes),
489+// 2. a reload signal re-arms the timer at the newly configured interval
490+// rather than the one the poller started with, and
491+// 3. cancelling the context makes the goroutine return instead of leaking.
492+func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
493+ clk := newPollerMockClock(t, 8)
494+ d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)
495+ d.clock = clk.clock
496+ d.barScripts.reload = make(chan struct{}, 1)
497+
498+ ctx, cancel := context.WithCancel(context.Background())
499+ done := make(chan struct{})
500+ go func() {
501+ d.barScriptPoller(ctx)
502+ close(done)
503+ }()
504+
505+ // Stage 1: the poller arms its first timer at the configured interval.
506+ var timer0 *pollerMockTimer
507+ select {
508+ case timer0 = <-clk.timers:
509+ case <-time.After(time.Second):
510+ t.Fatal("timed out waiting for the poller's initial timer")
511+ }
512+ require.Equal(t, 2*time.Second, timer0.duration)
513+
514+ // Stage 2: firing that timer processes the tick and re-arms a new one.
515+ timer0.ch <- time.Unix(0, 0)
516+ var timer1 *pollerMockTimer
517+ select {
518+ case timer1 = <-clk.timers:
519+ case <-time.After(time.Second):
520+ t.Fatal("timed out waiting for the poller to re-arm after a tick")
521+ }
522+ require.Equal(t, 2*time.Second, timer1.duration, "tick re-arm should keep the same interval when config is unchanged")
523+
524+ // Stage 3: changing the interval and signaling reload re-arms at the NEW
525+ // interval instead of waiting out the timer armed under the old one.
526+ d.barScripts.mu.Lock()
527+ d.barScripts.cfg.interval = 7 * time.Second
528+ d.barScripts.mu.Unlock()
529+ d.barScripts.reload <- struct{}{}
530+ var timer2 *pollerMockTimer
531+ select {
532+ case timer2 = <-clk.timers:
533+ case <-time.After(time.Second):
534+ t.Fatal("timed out waiting for the poller to re-arm after reload")
535+ }
536+ require.Equal(t, 7*time.Second, timer2.duration, "reload should re-arm at the newly configured interval")
537+
538+ // Stage 4: cancelling the context exits the goroutine instead of leaking it.
539+ cancel()
540+ select {
541+ case <-done:
542+ case <-time.After(time.Second):
543+ t.Fatal("barScriptPoller did not exit after context cancellation")
544+ }
545+}
546+
547+func TestApplyConfigSignalsPollerReload(t *testing.T) {
548+ r := &fakeBarRunner{}
549+ d := newBarRefreshTestDaemon(r, 60*time.Second)
550+ d.barScripts.reload = make(chan struct{}, 1)
551+
552+ cfg := domain.Defaults()
553+ cfg.Bar.TopRight = "t"
554+ cfg.Bar.BottomRight = "b"
555+ cfg.Bar.Interval = time.Second
556+ d.ApplyConfig(cfg)
557+
558+ select {
559+ case <-d.barScripts.reload:
560+ default:
561+ t.Fatal("ApplyConfig should signal the poller to re-arm its timer")
562+ }
563+ require.Equal(t, time.Second, d.barScriptInterval())
564+}
565+
328 func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {566 func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
329 d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{567 d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
330 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},568 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
@@ -360,3 +598,63 @@ func waitBarRefreshIdle(t *testing.T, d *Daemon) {
360 return len(d.barScripts.running) == 0598 return len(d.barScripts.running) == 0
361 }, time.Second, time.Millisecond)599 }, time.Second, time.Millisecond)
362 }600 }
601+
602+type envRecordingRunner struct {
603+ mu sync.Mutex
604+ envs [][]string
605+}
606+
607+func (r *envRecordingRunner) run(_ context.Context, _ string, env []string, _ barScriptContext) (string, error) {
608+ r.mu.Lock()
609+ defer r.mu.Unlock()
610+ r.envs = append(r.envs, append([]string(nil), env...))
611+ return "out", nil
612+}
613+
614+func (r *envRecordingRunner) captured() [][]string {
615+ r.mu.Lock()
616+ defer r.mu.Unlock()
617+ return append([][]string(nil), r.envs...)
618+}
619+
620+func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
621+ tests := []struct {
622+ name string
623+ sessEnv []string
624+ baseEnv []string
625+ wantPath string
626+ }{
627+ {
628+ name: "session env wins over stale daemon env",
629+ sessEnv: []string{"PATH=/home/u/.local/bin:/usr/bin"},
630+ baseEnv: []string{"PATH=/usr/bin"},
631+ wantPath: "PATH=/home/u/.local/bin:/usr/bin",
632+ },
633+ {
634+ name: "falls back to daemon env when session env empty",
635+ sessEnv: nil,
636+ baseEnv: []string{"PATH=/usr/bin"},
637+ wantPath: "PATH=/usr/bin",
638+ },
639+ }
640+
641+ for _, tc := range tests {
642+ t.Run(tc.name, func(t *testing.T) {
643+ r := &envRecordingRunner{}
644+ d := newBarRefreshTestDaemon(r, time.Second)
645+ d.baseEnv = tc.baseEnv
646+ sess := newBarRefreshTestSession()
647+ sess.env = tc.sessEnv
648+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
649+
650+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
651+ waitBarRefreshIdle(t, d)
652+
653+ envs := r.captured()
654+ require.Len(t, envs, 2, "one call per anchor")
655+ for _, env := range envs {
656+ require.Contains(t, env, tc.wantPath)
657+ }
658+ })
659+ }
660+}
M internal/usecase/daemon/bar_script.go +39 -6
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "fmt"
78 "strconv"
89 "strings"
910 "time"
@@ -14,6 +15,7 @@ import (
1415
1516 const (
1617 barScriptOutputLimit = 1024
18+ barScriptStderrLimit = 512
1719 barScriptDisplayLimit = 256
1820 barScriptTimeout = time.Second
1921 )
@@ -55,10 +57,9 @@ func (c barScriptContext) env(base []string) []string {
5557 type barScriptRunner struct {
5658 runner ports.ShellCommandRunner
5759 timeout time.Duration
58- baseEnv []string
5960 }
6061
61-func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) {
62+func (r barScriptRunner) run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error) {
6263 if strings.TrimSpace(command) == "" {
6364 return "", nil
6465 }
@@ -72,18 +73,23 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
7273 ctx, cancel := context.WithTimeout(ctx, timeout)
7374 defer cancel()
7475
75- stdout, err := r.runner.Run(ctx, ports.CommandSpec{
76+ res, err := r.runner.Run(ctx, ports.CommandSpec{
7677 Command: command,
77- Env: scriptCtx.env(r.baseEnv),
78+ Env: scriptCtx.env(env),
7879 Timeout: timeout,
7980 StdoutLimit: barScriptOutputLimit,
81+ StderrLimit: barScriptStderrLimit,
8082 })
8183 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
8284 return "", context.DeadlineExceeded
8385 }
84- text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit)
86+ text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit)
8587 if err != nil {
86- return text, err
88+ return text, &barScriptError{
89+ exitCode: res.ExitCode,
90+ stderr: sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit),
91+ err: err,
92+ }
8793 }
8894 return text, nil
8995 }
@@ -181,3 +187,30 @@ func trimUTF8Bytes(s string, limit int) string {
181187 }
182188 return s[:limit]
183189 }
190+
191+// barScriptError carries the diagnostic detail needed to explain why a bar
192+// script failed: the process exit code and whatever it wrote to stderr.
193+type barScriptError struct {
194+ exitCode int
195+ stderr string
196+ err error
197+}
198+
199+func (e *barScriptError) Error() string {
200+ if e.stderr != "" {
201+ return fmt.Sprintf("exit %d: %s", e.exitCode, e.stderr)
202+ }
203+ return fmt.Sprintf("exit %d", e.exitCode)
204+}
205+
206+func (e *barScriptError) Unwrap() error { return e.err }
207+
208+// pathFromEnv returns the PATH entry's value, or "" when unset.
209+func pathFromEnv(env []string) string {
210+ for _, entry := range env {
211+ if name, value, ok := environmentEntry(entry); ok && name == "PATH" {
212+ return value
213+ }
214+ }
215+ return ""
216+}
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "fmt"
7 "strconv"8 "strconv"
8 "strings"9 "strings"
9 "time"10 "time"
@@ -14,6 +15,7 @@ import (
14 15
15 const (16 const (
16 barScriptOutputLimit = 102417 barScriptOutputLimit = 1024
18+ barScriptStderrLimit = 512
17 barScriptDisplayLimit = 25619 barScriptDisplayLimit = 256
18 barScriptTimeout = time.Second20 barScriptTimeout = time.Second
19 )21 )
@@ -55,10 +57,9 @@ func (c barScriptContext) env(base []string) []string {
55 type barScriptRunner struct {57 type barScriptRunner struct {
56 runner ports.ShellCommandRunner58 runner ports.ShellCommandRunner
57 timeout time.Duration59 timeout time.Duration
58- baseEnv []string
59 }60 }
60 61
61-func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) {62+func (r barScriptRunner) run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error) {
62 if strings.TrimSpace(command) == "" {63 if strings.TrimSpace(command) == "" {
63 return "", nil64 return "", nil
64 }65 }
@@ -72,18 +73,23 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
72 ctx, cancel := context.WithTimeout(ctx, timeout)73 ctx, cancel := context.WithTimeout(ctx, timeout)
73 defer cancel()74 defer cancel()
74 75
75- stdout, err := r.runner.Run(ctx, ports.CommandSpec{76+ res, err := r.runner.Run(ctx, ports.CommandSpec{
76 Command: command,77 Command: command,
77- Env: scriptCtx.env(r.baseEnv),78+ Env: scriptCtx.env(env),
78 Timeout: timeout,79 Timeout: timeout,
79 StdoutLimit: barScriptOutputLimit,80 StdoutLimit: barScriptOutputLimit,
81+ StderrLimit: barScriptStderrLimit,
80 })82 })
81 if errors.Is(ctx.Err(), context.DeadlineExceeded) {83 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
82 return "", context.DeadlineExceeded84 return "", context.DeadlineExceeded
83 }85 }
84- text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit)86+ text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit)
85 if err != nil {87 if err != nil {
86- return text, err88+ return text, &barScriptError{
89+ exitCode: res.ExitCode,
90+ stderr: sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit),
91+ err: err,
92+ }
87 }93 }
88 return text, nil94 return text, nil
89 }95 }
@@ -181,3 +187,30 @@ func trimUTF8Bytes(s string, limit int) string {
181 }187 }
182 return s[:limit]188 return s[:limit]
183 }189 }
190+
191+// barScriptError carries the diagnostic detail needed to explain why a bar
192+// script failed: the process exit code and whatever it wrote to stderr.
193+type barScriptError struct {
194+ exitCode int
195+ stderr string
196+ err error
197+}
198+
199+func (e *barScriptError) Error() string {
200+ if e.stderr != "" {
201+ return fmt.Sprintf("exit %d: %s", e.exitCode, e.stderr)
202+ }
203+ return fmt.Sprintf("exit %d", e.exitCode)
204+}
205+
206+func (e *barScriptError) Unwrap() error { return e.err }
207+
208+// pathFromEnv returns the PATH entry's value, or "" when unset.
209+func pathFromEnv(env []string) string {
210+ for _, entry := range env {
211+ if name, value, ok := environmentEntry(entry); ok && name == "PATH" {
212+ return value
213+ }
214+ }
215+ return ""
216+}
M internal/usecase/daemon/bar_script_test.go +18 -14
@@ -85,7 +85,7 @@ func TestBarScriptContextEnv(t *testing.T) {
8585 func TestBarScriptRunner(t *testing.T) {
8686 t.Run("nil runner returns explicit error for command", func(t *testing.T) {
8787 runner := barScriptRunner{}
88- got, err := runner.run(context.Background(), "date", barScriptContext{})
88+ got, err := runner.run(context.Background(), "date", nil, barScriptContext{})
8989 if err == nil || err.Error() != "bar script command runner is nil" {
9090 t.Fatalf("run err = %v, want explicit nil runner error", err)
9191 }
@@ -96,7 +96,7 @@ func TestBarScriptRunner(t *testing.T) {
9696
9797 t.Run("empty command skips port runner", func(t *testing.T) {
9898 runner := barScriptRunner{}
99- got, err := runner.run(context.Background(), " \t\n ", barScriptContext{})
99+ got, err := runner.run(context.Background(), " \t\n ", nil, barScriptContext{})
100100 if err != nil {
101101 t.Fatalf("run err = %v, want nil", err)
102102 }
@@ -119,10 +119,12 @@ func TestBarScriptRunner(t *testing.T) {
119119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120120 containsEnv(spec.Env, "VEV_COLS=120") &&
121121 !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"}}
122+ })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
123+ runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}
124124
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})
125+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'",
126+ []string{"PATH=/bin", "VEV_ANCHOR=old"},
127+ barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
126128 if err != nil {
127129 t.Fatalf("run err = %v, want nil", err)
128130 }
@@ -134,10 +136,11 @@ func TestBarScriptRunner(t *testing.T) {
134136 t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
135137 wantErr := errors.New("exit 1")
136138 portRunner := portsmocks.NewMockShellCommandRunner(t)
137- portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr)
139+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).
140+ Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
138141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
139142
140- got, err := runner.run(context.Background(), "false", barScriptContext{})
143+ got, err := runner.run(context.Background(), "false", nil, barScriptContext{})
141144 if !errors.Is(err, wantErr) {
142145 t.Fatalf("run err = %v, want %v", err, wantErr)
143146 }
@@ -148,13 +151,14 @@ func TestBarScriptRunner(t *testing.T) {
148151
149152 t.Run("timeout propagates deadline", func(t *testing.T) {
150153 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- })
154+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(
155+ func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) {
156+ <-ctx.Done()
157+ return ports.CommandResult{ExitCode: -1}, ctx.Err()
158+ })
155159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
156160
157- _, err := runner.run(context.Background(), "sleep 1", barScriptContext{})
161+ _, err := runner.run(context.Background(), "sleep 1", nil, barScriptContext{})
158162 if !errors.Is(err, context.DeadlineExceeded) {
159163 t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
160164 }
@@ -165,10 +169,10 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
165169 portRunner := portsmocks.NewMockShellCommandRunner(t)
166170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
167171 return spec.StdoutLimit == barScriptOutputLimit
168- })).Return([]byte(strings.Repeat("a", 4096)), nil)
172+ })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
169173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
170174
171- got, err := runner.run(context.Background(), "long-output", barScriptContext{})
175+ got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})
172176 if err != nil {
173177 t.Fatalf("run bounded output: %v", err)
174178 }
@@ -85,7 +85,7 @@ func TestBarScriptContextEnv(t *testing.T) {
85 func TestBarScriptRunner(t *testing.T) {85 func TestBarScriptRunner(t *testing.T) {
86 t.Run("nil runner returns explicit error for command", func(t *testing.T) {86 t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87 runner := barScriptRunner{}87 runner := barScriptRunner{}
88- got, err := runner.run(context.Background(), "date", barScriptContext{})88+ got, err := runner.run(context.Background(), "date", nil, barScriptContext{})
89 if err == nil || err.Error() != "bar script command runner is nil" {89 if err == nil || err.Error() != "bar script command runner is nil" {
90 t.Fatalf("run err = %v, want explicit nil runner error", err)90 t.Fatalf("run err = %v, want explicit nil runner error", err)
91 }91 }
@@ -96,7 +96,7 @@ func TestBarScriptRunner(t *testing.T) {
96 96
97 t.Run("empty command skips port runner", func(t *testing.T) {97 t.Run("empty command skips port runner", func(t *testing.T) {
98 runner := barScriptRunner{}98 runner := barScriptRunner{}
99- got, err := runner.run(context.Background(), " \t\n ", barScriptContext{})99+ got, err := runner.run(context.Background(), " \t\n ", nil, barScriptContext{})
100 if err != nil {100 if err != nil {
101 t.Fatalf("run err = %v, want nil", err)101 t.Fatalf("run err = %v, want nil", err)
102 }102 }
@@ -119,10 +119,12 @@ func TestBarScriptRunner(t *testing.T) {
119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120 containsEnv(spec.Env, "VEV_COLS=120") &&120 containsEnv(spec.Env, "VEV_COLS=120") &&
121 !containsEnv(spec.Env, "VEV_ANCHOR=old")121 !containsEnv(spec.Env, "VEV_ANCHOR=old")
122- })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil)122+ })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
123- runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}123+ runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}
124 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})125+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'",
126+ []string{"PATH=/bin", "VEV_ANCHOR=old"},
127+ barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
126 if err != nil {128 if err != nil {
127 t.Fatalf("run err = %v, want nil", err)129 t.Fatalf("run err = %v, want nil", err)
128 }130 }
@@ -134,10 +136,11 @@ func TestBarScriptRunner(t *testing.T) {
134 t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {136 t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
135 wantErr := errors.New("exit 1")137 wantErr := errors.New("exit 1")
136 portRunner := portsmocks.NewMockShellCommandRunner(t)138 portRunner := portsmocks.NewMockShellCommandRunner(t)
137- portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr)139+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).
140+ Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
138 runner := barScriptRunner{runner: portRunner, timeout: time.Second}141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
139 142
140- got, err := runner.run(context.Background(), "false", barScriptContext{})143+ got, err := runner.run(context.Background(), "false", nil, barScriptContext{})
141 if !errors.Is(err, wantErr) {144 if !errors.Is(err, wantErr) {
142 t.Fatalf("run err = %v, want %v", err, wantErr)145 t.Fatalf("run err = %v, want %v", err, wantErr)
143 }146 }
@@ -148,13 +151,14 @@ func TestBarScriptRunner(t *testing.T) {
148 151
149 t.Run("timeout propagates deadline", func(t *testing.T) {152 t.Run("timeout propagates deadline", func(t *testing.T) {
150 portRunner := portsmocks.NewMockShellCommandRunner(t)153 portRunner := portsmocks.NewMockShellCommandRunner(t)
151- portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {154+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(
152- <-ctx.Done()155+ func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) {
153- return nil, ctx.Err()156+ <-ctx.Done()
154- })157+ return ports.CommandResult{ExitCode: -1}, ctx.Err()
158+ })
155 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
156 160
157- _, err := runner.run(context.Background(), "sleep 1", barScriptContext{})161+ _, err := runner.run(context.Background(), "sleep 1", nil, barScriptContext{})
158 if !errors.Is(err, context.DeadlineExceeded) {162 if !errors.Is(err, context.DeadlineExceeded) {
159 t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)163 t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
160 }164 }
@@ -165,10 +169,10 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
165 portRunner := portsmocks.NewMockShellCommandRunner(t)169 portRunner := portsmocks.NewMockShellCommandRunner(t)
166 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
167 return spec.StdoutLimit == barScriptOutputLimit171 return spec.StdoutLimit == barScriptOutputLimit
168- })).Return([]byte(strings.Repeat("a", 4096)), nil)172+ })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
169 runner := barScriptRunner{runner: portRunner, timeout: time.Second}173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
170 174
171- got, err := runner.run(context.Background(), "long-output", barScriptContext{})175+ got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})
172 if err != nil {176 if err != nil {
173 t.Fatalf("run bounded output: %v", err)177 t.Fatalf("run bounded output: %v", err)
174 }178 }
M internal/usecase/daemon/config.go +7 -0
@@ -57,6 +57,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
5757 tabs := cfg.Tabs
5858 d.tabsConfig.Store(&tabs)
5959 d.storeThemeConfig(cfg)
60+ barChanged := false
6061 if d.barScripts != nil {
6162 d.barScripts.mu.Lock()
6263 d.barScripts.initLocked()
@@ -67,10 +68,16 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
6768 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
6869 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
6970 d.barScripts.pending = make(map[domain.SessionID]bool)
71+ d.barScripts.lastFailure = make(map[barScriptFailureKey]string)
7072 d.barScripts.version++
73+ barChanged = true
7174 }
7275 d.barScripts.mu.Unlock()
7376 }
77+ if barChanged {
78+ d.signalBarPollerReload()
79+ d.refreshBarScriptsAllSessions()
80+ }
7481 d.reapplyThemeAllSessions()
7582 d.repaintAllAttachedClients()
7683 }
@@ -57,6 +57,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
57 tabs := cfg.Tabs57 tabs := cfg.Tabs
58 d.tabsConfig.Store(&tabs)58 d.tabsConfig.Store(&tabs)
59 d.storeThemeConfig(cfg)59 d.storeThemeConfig(cfg)
60+ barChanged := false
60 if d.barScripts != nil {61 if d.barScripts != nil {
61 d.barScripts.mu.Lock()62 d.barScripts.mu.Lock()
62 d.barScripts.initLocked()63 d.barScripts.initLocked()
@@ -67,10 +68,16 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
67 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)68 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
68 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)69 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
69 d.barScripts.pending = make(map[domain.SessionID]bool)70 d.barScripts.pending = make(map[domain.SessionID]bool)
71+ d.barScripts.lastFailure = make(map[barScriptFailureKey]string)
70 d.barScripts.version++72 d.barScripts.version++
73+ barChanged = true
71 }74 }
72 d.barScripts.mu.Unlock()75 d.barScripts.mu.Unlock()
73 }76 }
77+ if barChanged {
78+ d.signalBarPollerReload()
79+ d.refreshBarScriptsAllSessions()
80+ }
74 d.reapplyThemeAllSessions()81 d.reapplyThemeAllSessions()
75 d.repaintAllAttachedClients()82 d.repaintAllAttachedClients()
76 }83 }
M internal/usecase/daemon/daemon.go +2 -1
@@ -283,7 +283,7 @@ func WithTempDir(dir string) Option {
283283 func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
284284 return func(d *Daemon) {
285285 if runner != nil {
286- d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv}
286+ d.barScripts.runner = barScriptRunner{runner: runner}
287287 }
288288 }
289289 }
@@ -357,6 +357,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
357357 lastRefresh: make(map[domain.SessionID]time.Time),
358358 lastContext: make(map[domain.SessionID]barScriptContext),
359359 running: make(map[domain.SessionID]bool),
360+ reload: make(chan struct{}, 1),
360361 },
361362 }
362363 defaults := domain.Defaults()
@@ -283,7 +283,7 @@ func WithTempDir(dir string) Option {
283 func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {283 func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
284 return func(d *Daemon) {284 return func(d *Daemon) {
285 if runner != nil {285 if runner != nil {
286- d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv}286+ d.barScripts.runner = barScriptRunner{runner: runner}
287 }287 }
288 }288 }
289 }289 }
@@ -357,6 +357,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
357 lastRefresh: make(map[domain.SessionID]time.Time),357 lastRefresh: make(map[domain.SessionID]time.Time),
358 lastContext: make(map[domain.SessionID]barScriptContext),358 lastContext: make(map[domain.SessionID]barScriptContext),
359 running: make(map[domain.SessionID]bool),359 running: make(map[domain.SessionID]bool),
360+ reload: make(chan struct{}, 1),
360 },361 },
361 }362 }
362 defaults := domain.Defaults()363 defaults := domain.Defaults()

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