detached ce26e86

fix(daemon): address claude bar anchor review

Signed-off-by: brice <b@bnema.dev>
commit
ce26e86aa36b2f39b2798f3d55db479fec33d715
author
brice · · 2026-07-06 15:16 UTC
parent
a0fb45a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +186 -15
M internal/usecase/daemon/bar_refresh.go +63 -5
@@ -33,6 +33,7 @@ type barScriptState struct {
3333 lastRefresh map[domain.SessionID]time.Time
3434 lastContext map[domain.SessionID]barScriptContext
3535 running map[domain.SessionID]bool
36+ pending map[domain.SessionID]bool
3637 version uint64
3738 }
3839
@@ -63,6 +64,9 @@ func (s *barScriptState) initLocked() {
6364 if s.running == nil {
6465 s.running = make(map[domain.SessionID]bool)
6566 }
67+ if s.pending == nil {
68+ s.pending = make(map[domain.SessionID]bool)
69+ }
6670 }
6771
6872 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
@@ -157,6 +161,7 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
157161 delete(d.barScripts.lastRefresh, id)
158162 delete(d.barScripts.lastContext, id)
159163 delete(d.barScripts.running, id)
164+ delete(d.barScripts.pending, id)
160165 }
161166
162167 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
@@ -172,7 +177,15 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
172177 if d.barScripts.lastContext[sess.id] != baseCtx {
173178 force = true
174179 }
175- if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) {
180+ lastRefresh := d.barScripts.lastRefresh[sess.id]
181+ if d.barScripts.running[sess.id] || (!lastRefresh.IsZero() && now.Sub(lastRefresh) < minBarScriptInterval) {
182+ if force {
183+ d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(minBarScriptInterval).Sub(now))
184+ }
185+ d.barScripts.mu.Unlock()
186+ return false
187+ }
188+ if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
176189 d.barScripts.mu.Unlock()
177190 return false
178191 }
@@ -187,6 +200,45 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
187200 return true
188201 }
189202
203+func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
204+ if d.barScripts.pending[sess.id] {
205+ return
206+ }
207+ if delay < 0 {
208+ delay = 0
209+ }
210+ d.barScripts.pending[sess.id] = true
211+ go func() {
212+ ctx := sess.ctx
213+ if ctx == nil {
214+ ctx = context.Background()
215+ }
216+ if d.clock != nil {
217+ timer := d.clock.NewTimer(delay)
218+ select {
219+ case <-ctx.Done():
220+ timer.Stop()
221+ return
222+ case <-timer.C():
223+ }
224+ } else {
225+ select {
226+ case <-ctx.Done():
227+ return
228+ case <-time.After(delay):
229+ }
230+ }
231+ d.barScripts.mu.Lock()
232+ delete(d.barScripts.pending, sess.id)
233+ d.barScripts.mu.Unlock()
234+ now := time.Now()
235+ if d.clock != nil {
236+ now = d.clock.Now()
237+ }
238+ d.refreshBarScriptsIfDue(sess, now, true)
239+ }()
240+}
241+
190242 func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {
191243 if runner == nil {
192244 runner = barScriptRunner{baseEnv: d.baseEnv}
@@ -195,8 +247,8 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
195247 if ctx == nil {
196248 ctx = context.Background()
197249 }
198- top, topOK := runOneBarScript(ctx, runner, cfg.topRight, base, "top-right")
199- bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right")
250+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name)
251+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name)
200252 d.barScripts.mu.Lock()
201253 d.barScripts.initLocked()
202254 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
@@ -222,13 +274,19 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
222274 }
223275 }
224276
225-func runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor string) (string, bool) {
277+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
226278 if command == "" {
227279 return "", true
228280 }
229281 base.Anchor = anchor
230282 out, err := runner.run(ctx, command, base)
231- return out, err == nil
283+ if err != nil {
284+ if d.log != nil {
285+ d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
286+ }
287+ return "", false
288+ }
289+ return out, true
232290 }
233291
234292 func (d *Daemon) pokeSessionRender(sess *session) {
@@ -33,6 +33,7 @@ type barScriptState struct {
33 lastRefresh map[domain.SessionID]time.Time33 lastRefresh map[domain.SessionID]time.Time
34 lastContext map[domain.SessionID]barScriptContext34 lastContext map[domain.SessionID]barScriptContext
35 running map[domain.SessionID]bool35 running map[domain.SessionID]bool
36+ pending map[domain.SessionID]bool
36 version uint6437 version uint64
37 }38 }
38 39
@@ -63,6 +64,9 @@ func (s *barScriptState) initLocked() {
63 if s.running == nil {64 if s.running == nil {
64 s.running = make(map[domain.SessionID]bool)65 s.running = make(map[domain.SessionID]bool)
65 }66 }
67+ if s.pending == nil {
68+ s.pending = make(map[domain.SessionID]bool)
69+ }
66 }70 }
67 71
68 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {72 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
@@ -157,6 +161,7 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
157 delete(d.barScripts.lastRefresh, id)161 delete(d.barScripts.lastRefresh, id)
158 delete(d.barScripts.lastContext, id)162 delete(d.barScripts.lastContext, id)
159 delete(d.barScripts.running, id)163 delete(d.barScripts.running, id)
164+ delete(d.barScripts.pending, id)
160 }165 }
161 166
162 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {167 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
@@ -172,7 +177,15 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
172 if d.barScripts.lastContext[sess.id] != baseCtx {177 if d.barScripts.lastContext[sess.id] != baseCtx {
173 force = true178 force = true
174 }179 }
175- if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) {180+ lastRefresh := d.barScripts.lastRefresh[sess.id]
181+ if d.barScripts.running[sess.id] || (!lastRefresh.IsZero() && now.Sub(lastRefresh) < minBarScriptInterval) {
182+ if force {
183+ d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(minBarScriptInterval).Sub(now))
184+ }
185+ d.barScripts.mu.Unlock()
186+ return false
187+ }
188+ if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
176 d.barScripts.mu.Unlock()189 d.barScripts.mu.Unlock()
177 return false190 return false
178 }191 }
@@ -187,6 +200,45 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
187 return true200 return true
188 }201 }
189 202
203+func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
204+ if d.barScripts.pending[sess.id] {
205+ return
206+ }
207+ if delay < 0 {
208+ delay = 0
209+ }
210+ d.barScripts.pending[sess.id] = true
211+ go func() {
212+ ctx := sess.ctx
213+ if ctx == nil {
214+ ctx = context.Background()
215+ }
216+ if d.clock != nil {
217+ timer := d.clock.NewTimer(delay)
218+ select {
219+ case <-ctx.Done():
220+ timer.Stop()
221+ return
222+ case <-timer.C():
223+ }
224+ } else {
225+ select {
226+ case <-ctx.Done():
227+ return
228+ case <-time.After(delay):
229+ }
230+ }
231+ d.barScripts.mu.Lock()
232+ delete(d.barScripts.pending, sess.id)
233+ d.barScripts.mu.Unlock()
234+ now := time.Now()
235+ if d.clock != nil {
236+ now = d.clock.Now()
237+ }
238+ d.refreshBarScriptsIfDue(sess, now, true)
239+ }()
240+}
241+
190 func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {242 func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {
191 if runner == nil {243 if runner == nil {
192 runner = barScriptRunner{baseEnv: d.baseEnv}244 runner = barScriptRunner{baseEnv: d.baseEnv}
@@ -195,8 +247,8 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
195 if ctx == nil {247 if ctx == nil {
196 ctx = context.Background()248 ctx = context.Background()
197 }249 }
198- top, topOK := runOneBarScript(ctx, runner, cfg.topRight, base, "top-right")250+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name)
199- bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right")251+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name)
200 d.barScripts.mu.Lock()252 d.barScripts.mu.Lock()
201 d.barScripts.initLocked()253 d.barScripts.initLocked()
202 if !d.barScripts.running[sess.id] || d.barScripts.version != version {254 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
@@ -222,13 +274,19 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
222 }274 }
223 }275 }
224 276
225-func runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor string) (string, bool) {277+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
226 if command == "" {278 if command == "" {
227 return "", true279 return "", true
228 }280 }
229 base.Anchor = anchor281 base.Anchor = anchor
230 out, err := runner.run(ctx, command, base)282 out, err := runner.run(ctx, command, base)
231- return out, err == nil283+ if err != nil {
284+ if d.log != nil {
285+ d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
286+ }
287+ return "", false
288+ }
289+ return out, true
232 }290 }
233 291
234 func (d *Daemon) pokeSessionRender(sess *session) {292 func (d *Daemon) pokeSessionRender(sess *session) {
M internal/usecase/daemon/bar_refresh_test.go +68 -0
@@ -1,8 +1,11 @@
11 package daemon
22
33 import (
4+ "bytes"
45 "context"
56 "errors"
7+ "log/slog"
8+ "strings"
69 "sync"
710 "testing"
811 "time"
@@ -98,6 +101,36 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
98101 require.Empty(t, r.calls)
99102 }
100103
104+func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
105+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
106+ d := newBarRefreshTestDaemon(r, 5*time.Second)
107+ sess := newBarRefreshTestSession()
108+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
109+
110+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
111+ waitBarRefreshIdle(t, d)
112+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
113+ require.Len(t, r.calls, 2)
114+}
115+
116+func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
117+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
118+ d := newBarRefreshTestDaemon(r, 5*time.Second)
119+ sess := newBarRefreshTestSession()
120+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
121+
122+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
123+ waitBarRefreshIdle(t, d)
124+ sess.cwd = "/other"
125+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false))
126+ require.Eventually(t, func() bool {
127+ r.mu.Lock()
128+ defer r.mu.Unlock()
129+ return len(r.calls) == 4
130+ }, 1500*time.Millisecond, 10*time.Millisecond)
131+ require.Equal(t, "/other", r.calls[2].PaneCWD)
132+}
133+
101134 func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
102135 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
103136 d := newBarRefreshTestDaemon(r, 5*time.Second)
@@ -113,6 +146,40 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
113146 require.Equal(t, "/other", r.calls[2].PaneCWD)
114147 }
115148
149+func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
150+ r := &fakeBarRunner{}
151+ d := newBarRefreshTestDaemon(r, time.Second)
152+ sess := newBarRefreshTestSession()
153+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"}
154+
155+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
156+
157+ state := d.barStateFor(sess, "")
158+ require.Equal(t, "top-good", state.topRight)
159+ require.Equal(t, "bottom-good", state.bottomRight)
160+}
161+
162+func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
163+ var logs bytes.Buffer
164+ r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
165+ d := newBarRefreshTestDaemon(r, time.Second)
166+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
167+ sess := newBarRefreshTestSession()
168+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
169+
170+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
171+ waitBarRefreshIdle(t, d)
172+ r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")}
173+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
174+ waitBarRefreshIdle(t, d)
175+
176+ state := d.barStateFor(sess, "")
177+ require.Equal(t, "top-good", state.topRight)
178+ require.Equal(t, "bottom-good", state.bottomRight)
179+ require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String())
180+ require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String())
181+}
182+
116183 func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
117184 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
118185 d := newBarRefreshTestDaemon(r, time.Second)
@@ -178,6 +245,7 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
178245 lastRefresh: make(map[domain.SessionID]time.Time),
179246 lastContext: make(map[domain.SessionID]barScriptContext),
180247 running: make(map[domain.SessionID]bool),
248+ pending: make(map[domain.SessionID]bool),
181249 }}
182250 return d
183251 }
@@ -1,8 +1,11 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "errors"6 "errors"
7+ "log/slog"
8+ "strings"
6 "sync"9 "sync"
7 "testing"10 "testing"
8 "time"11 "time"
@@ -98,6 +101,36 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
98 require.Empty(t, r.calls)101 require.Empty(t, r.calls)
99 }102 }
100 103
104+func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
105+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
106+ d := newBarRefreshTestDaemon(r, 5*time.Second)
107+ sess := newBarRefreshTestSession()
108+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
109+
110+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
111+ waitBarRefreshIdle(t, d)
112+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
113+ require.Len(t, r.calls, 2)
114+}
115+
116+func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
117+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
118+ d := newBarRefreshTestDaemon(r, 5*time.Second)
119+ sess := newBarRefreshTestSession()
120+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
121+
122+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
123+ waitBarRefreshIdle(t, d)
124+ sess.cwd = "/other"
125+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false))
126+ require.Eventually(t, func() bool {
127+ r.mu.Lock()
128+ defer r.mu.Unlock()
129+ return len(r.calls) == 4
130+ }, 1500*time.Millisecond, 10*time.Millisecond)
131+ require.Equal(t, "/other", r.calls[2].PaneCWD)
132+}
133+
101 func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {134 func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
102 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}135 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
103 d := newBarRefreshTestDaemon(r, 5*time.Second)136 d := newBarRefreshTestDaemon(r, 5*time.Second)
@@ -113,6 +146,40 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
113 require.Equal(t, "/other", r.calls[2].PaneCWD)146 require.Equal(t, "/other", r.calls[2].PaneCWD)
114 }147 }
115 148
149+func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
150+ r := &fakeBarRunner{}
151+ d := newBarRefreshTestDaemon(r, time.Second)
152+ sess := newBarRefreshTestSession()
153+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"}
154+
155+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
156+
157+ state := d.barStateFor(sess, "")
158+ require.Equal(t, "top-good", state.topRight)
159+ require.Equal(t, "bottom-good", state.bottomRight)
160+}
161+
162+func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
163+ var logs bytes.Buffer
164+ r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
165+ d := newBarRefreshTestDaemon(r, time.Second)
166+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
167+ sess := newBarRefreshTestSession()
168+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
169+
170+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
171+ waitBarRefreshIdle(t, d)
172+ r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")}
173+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
174+ waitBarRefreshIdle(t, d)
175+
176+ state := d.barStateFor(sess, "")
177+ require.Equal(t, "top-good", state.topRight)
178+ require.Equal(t, "bottom-good", state.bottomRight)
179+ require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String())
180+ require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String())
181+}
182+
116 func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {183 func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
117 r := &fakeBarRunner{outs: []string{"top", "bottom"}}184 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
118 d := newBarRefreshTestDaemon(r, time.Second)185 d := newBarRefreshTestDaemon(r, time.Second)
@@ -178,6 +245,7 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
178 lastRefresh: make(map[domain.SessionID]time.Time),245 lastRefresh: make(map[domain.SessionID]time.Time),
179 lastContext: make(map[domain.SessionID]barScriptContext),246 lastContext: make(map[domain.SessionID]barScriptContext),
180 running: make(map[domain.SessionID]bool),247 running: make(map[domain.SessionID]bool),
248+ pending: make(map[domain.SessionID]bool),
181 }}249 }}
182 return d250 return d
183 }251 }
M internal/usecase/daemon/bar_script.go +15 -1
@@ -8,6 +8,7 @@ import (
88 "os/exec"
99 "strconv"
1010 "strings"
11+ "syscall"
1112 "time"
1213 "unicode/utf8"
1314 )
@@ -70,6 +71,14 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
7071
7172 cmd := exec.CommandContext(ctx, "sh", "-c", command)
7273 cmd.Env = scriptCtx.env(r.baseEnv)
74+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
75+ cmd.WaitDelay = timeout
76+ cmd.Cancel = func() error {
77+ if cmd.Process == nil {
78+ return nil
79+ }
80+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
81+ }
7382 var stdout boundedBuffer
7483 stdout.limit = barScriptOutputLimit
7584 cmd.Stdout = &stdout
@@ -156,9 +165,14 @@ func skipEscapeSequence(s string, i int) int {
156165 return skipCSI(s, i+1)
157166 case ']', 'P', '^', '_', 'X':
158167 return skipStringControl(s, i+1)
159- default:
168+ }
169+ for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
170+ i++
171+ }
172+ if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e {
160173 return i + 1
161174 }
175+ return i
162176 }
163177
164178 func skipCSI(s string, i int) int {
@@ -8,6 +8,7 @@ import (
8 "os/exec"8 "os/exec"
9 "strconv"9 "strconv"
10 "strings"10 "strings"
11+ "syscall"
11 "time"12 "time"
12 "unicode/utf8"13 "unicode/utf8"
13 )14 )
@@ -70,6 +71,14 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
70 71
71 cmd := exec.CommandContext(ctx, "sh", "-c", command)72 cmd := exec.CommandContext(ctx, "sh", "-c", command)
72 cmd.Env = scriptCtx.env(r.baseEnv)73 cmd.Env = scriptCtx.env(r.baseEnv)
74+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
75+ cmd.WaitDelay = timeout
76+ cmd.Cancel = func() error {
77+ if cmd.Process == nil {
78+ return nil
79+ }
80+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
81+ }
73 var stdout boundedBuffer82 var stdout boundedBuffer
74 stdout.limit = barScriptOutputLimit83 stdout.limit = barScriptOutputLimit
75 cmd.Stdout = &stdout84 cmd.Stdout = &stdout
@@ -156,9 +165,14 @@ func skipEscapeSequence(s string, i int) int {
156 return skipCSI(s, i+1)165 return skipCSI(s, i+1)
157 case ']', 'P', '^', '_', 'X':166 case ']', 'P', '^', '_', 'X':
158 return skipStringControl(s, i+1)167 return skipStringControl(s, i+1)
159- default:168+ }
169+ for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
170+ i++
171+ }
172+ if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e {
160 return i + 1173 return i + 1
161 }174 }
175+ return i
162 }176 }
163 177
164 func skipCSI(s string, i int) int {178 func skipCSI(s string, i int) int {
M internal/usecase/daemon/bar_script_test.go +14 -0
@@ -20,6 +20,8 @@ func TestBarScriptSanitizeOutput(t *testing.T) {
2020 {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
2121 {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
2222 {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},
23+ {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"},
24+ {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"},
2325 {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
2426 {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
2527 {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
@@ -100,6 +102,18 @@ func TestBarScriptRunner(t *testing.T) {
100102 }
101103 }
102104
105+func TestBarScriptRunnerBackgroundChildStdoutTimeout(t *testing.T) {
106+ runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}
107+ start := time.Now()
108+ _, err := runner.run(context.Background(), "sleep 1 &", barScriptContext{})
109+ if err == nil {
110+ t.Fatalf("background child run err = nil, want timeout/wait error")
111+ }
112+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
113+ t.Fatalf("background child run elapsed = %s, want <= 500ms", elapsed)
114+ }
115+}
116+
103117 func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
104118 runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()}
105119 got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{})
@@ -20,6 +20,8 @@ func TestBarScriptSanitizeOutput(t *testing.T) {
20 {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},20 {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
21 {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},21 {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
22 {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},22 {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},
23+ {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"},
24+ {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"},
23 {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},25 {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
24 {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},26 {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
25 {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},27 {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
@@ -100,6 +102,18 @@ func TestBarScriptRunner(t *testing.T) {
100 }102 }
101 }103 }
102 104
105+func TestBarScriptRunnerBackgroundChildStdoutTimeout(t *testing.T) {
106+ runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}
107+ start := time.Now()
108+ _, err := runner.run(context.Background(), "sleep 1 &", barScriptContext{})
109+ if err == nil {
110+ t.Fatalf("background child run err = nil, want timeout/wait error")
111+ }
112+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
113+ t.Fatalf("background child run elapsed = %s, want <= 500ms", elapsed)
114+ }
115+}
116+
103 func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {117 func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
104 runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()}118 runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()}
105 got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{})119 got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{})
M internal/usecase/daemon/config.go +10 -5
@@ -30,11 +30,16 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
3030 d.themeMode.Store(uint32(cfg.Theme))
3131 if d.barScripts != nil {
3232 d.barScripts.mu.Lock()
33- d.barScripts.cfg = barConfigFromDomain(cfg.Bar)
34- d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
35- d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
36- d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
37- d.barScripts.version++
33+ d.barScripts.initLocked()
34+ barCfg := barConfigFromDomain(cfg.Bar)
35+ if d.barScripts.cfg != barCfg {
36+ d.barScripts.cfg = barCfg
37+ d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
38+ d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
39+ d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
40+ d.barScripts.pending = make(map[domain.SessionID]bool)
41+ d.barScripts.version++
42+ }
3843 d.barScripts.mu.Unlock()
3944 }
4045 d.reapplyThemeAllSessions()
@@ -30,11 +30,16 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
30 d.themeMode.Store(uint32(cfg.Theme))30 d.themeMode.Store(uint32(cfg.Theme))
31 if d.barScripts != nil {31 if d.barScripts != nil {
32 d.barScripts.mu.Lock()32 d.barScripts.mu.Lock()
33- d.barScripts.cfg = barConfigFromDomain(cfg.Bar)33+ d.barScripts.initLocked()
34- d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)34+ barCfg := barConfigFromDomain(cfg.Bar)
35- d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)35+ if d.barScripts.cfg != barCfg {
36- d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)36+ d.barScripts.cfg = barCfg
37- d.barScripts.version++37+ d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
38+ d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
39+ d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
40+ d.barScripts.pending = make(map[domain.SessionID]bool)
41+ d.barScripts.version++
42+ }
38 d.barScripts.mu.Unlock()43 d.barScripts.mu.Unlock()
39 }44 }
40 d.reapplyThemeAllSessions()45 d.reapplyThemeAllSessions()
M internal/usecase/daemon/status.go +1 -1
@@ -231,7 +231,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
231231 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
232232 copyReserve := 1
233233 if feedback != "" {
234- copyReserve = len([]rune(feedback)) + 2
234+ copyReserve = statusTextWidth(feedback) + 2
235235 }
236236 physicalBudget := rowLen - leftUsed - copyReserve
237237 if physicalBudget <= 0 || len(entries) == 0 {
@@ -231,7 +231,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
231 // its " text" width plus a one-cell gap so drawRightPlainText always fits.231 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
232 copyReserve := 1232 copyReserve := 1
233 if feedback != "" {233 if feedback != "" {
234- copyReserve = len([]rune(feedback)) + 2234+ copyReserve = statusTextWidth(feedback) + 2
235 }235 }
236 physicalBudget := rowLen - leftUsed - copyReserve236 physicalBudget := rowLen - leftUsed - copyReserve
237 if physicalBudget <= 0 || len(entries) == 0 {237 if physicalBudget <= 0 || len(entries) == 0 {
M internal/usecase/daemon/status_test.go +13 -0
@@ -716,6 +716,19 @@ func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) {
716716 require.True(t, strings.HasSuffix(text, " git"), text)
717717 }
718718
719+func TestStatusBarMRUFittingReservesWideRightAnchorWidth(t *testing.T) {
720+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}}
721+ row := make([]renderer.Cell, 12)
722+
723+ drawStatusBarState(row, state, resolveThemeStyles(nil))
724+
725+ text := rowText(row)
726+ require.NotContains(t, text, " a ")
727+ require.True(t, strings.HasSuffix(text, " 界 界 "), text)
728+ require.True(t, row[9].Continuation)
729+ require.True(t, row[11].Continuation)
730+}
731+
719732 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
720733 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
721734 row := make([]renderer.Cell, 20)
@@ -716,6 +716,19 @@ func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) {
716 require.True(t, strings.HasSuffix(text, " git"), text)716 require.True(t, strings.HasSuffix(text, " git"), text)
717 }717 }
718 718
719+func TestStatusBarMRUFittingReservesWideRightAnchorWidth(t *testing.T) {
720+ state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}}
721+ row := make([]renderer.Cell, 12)
722+
723+ drawStatusBarState(row, state, resolveThemeStyles(nil))
724+
725+ text := rowText(row)
726+ require.NotContains(t, text, " a ")
727+ require.True(t, strings.HasSuffix(text, " 界 界 "), text)
728+ require.True(t, row[9].Continuation)
729+ require.True(t, row[11].Continuation)
730+}
731+
719 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {732 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
720 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}733 state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
721 row := make([]renderer.Cell, 20)734 row := make([]renderer.Cell, 20)
M scripts/vev-bar-bottom-right +2 -3
@@ -26,9 +26,8 @@ line=$name
2626
2727 counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true)
2828 if [ -n "$counts" ]; then
29- set -- $counts
30- behind=${1:-0}
31- ahead=${2:-0}
29+ behind=$(printf '%s\n' "$counts" | awk '{ print $1 }')
30+ ahead=$(printf '%s\n' "$counts" | awk '{ print $2 }')
3231 case $ahead in
3332 ''|*[!0-9]*) ahead=0 ;;
3433 esac
@@ -26,9 +26,8 @@ line=$name
26 26
27 counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true)27 counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true)
28 if [ -n "$counts" ]; then28 if [ -n "$counts" ]; then
29- set -- $counts29+ behind=$(printf '%s\n' "$counts" | awk '{ print $1 }')
30- behind=${1:-0}30+ ahead=$(printf '%s\n' "$counts" | awk '{ print $2 }')
31- ahead=${2:-0}
32 case $ahead in31 case $ahead in
33 ''|*[!0-9]*) ahead=0 ;;32 ''|*[!0-9]*) ahead=0 ;;
34 esac33 esac

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