detached 423c15f

fix(daemon): harden bar anchor refresh and output handling

Signed-off-by: brice <b@bnema.dev>
commit
423c15fcb8b27a6e2e98bd72d6484919d695ca61
author
brice · · 2026-07-06 14:37 UTC
parent
5e82673
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +155 -22
M internal/adapters/config/config.go +13 -2
@@ -8,6 +8,7 @@ import (
88 "io"
99 "os"
1010 "regexp"
11+ "strconv"
1112 "strings"
1213 "time"
1314
@@ -64,9 +65,9 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6465 }
6566 cfg.Theme = mode
6667 case key == "bar.top-right":
67- cfg.Bar.TopRight = value
68+ cfg.Bar.TopRight = parseBarCommand(value)
6869 case key == "bar.bottom-right":
69- cfg.Bar.BottomRight = value
70+ cfg.Bar.BottomRight = parseBarCommand(value)
7071 case key == "bar.interval":
7172 interval, err := time.ParseDuration(value)
7273 if err != nil {
@@ -216,6 +217,16 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
216217 return out, warnings
217218 }
218219
220+func parseBarCommand(value string) string {
221+ value = strings.TrimSpace(value)
222+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
223+ if unquoted, err := strconv.Unquote(value); err == nil {
224+ return unquoted
225+ }
226+ }
227+ return value
228+}
229+
219230 func parseTheme(value string) (domain.ThemeMode, bool) {
220231 switch strings.ToLower(strings.TrimSpace(value)) {
221232 case "auto", "":
@@ -8,6 +8,7 @@ import (
8 "io"8 "io"
9 "os"9 "os"
10 "regexp"10 "regexp"
11+ "strconv"
11 "strings"12 "strings"
12 "time"13 "time"
13 14
@@ -64,9 +65,9 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
64 }65 }
65 cfg.Theme = mode66 cfg.Theme = mode
66 case key == "bar.top-right":67 case key == "bar.top-right":
67- cfg.Bar.TopRight = value68+ cfg.Bar.TopRight = parseBarCommand(value)
68 case key == "bar.bottom-right":69 case key == "bar.bottom-right":
69- cfg.Bar.BottomRight = value70+ cfg.Bar.BottomRight = parseBarCommand(value)
70 case key == "bar.interval":71 case key == "bar.interval":
71 interval, err := time.ParseDuration(value)72 interval, err := time.ParseDuration(value)
72 if err != nil {73 if err != nil {
@@ -216,6 +217,16 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
216 return out, warnings217 return out, warnings
217 }218 }
218 219
220+func parseBarCommand(value string) string {
221+ value = strings.TrimSpace(value)
222+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
223+ if unquoted, err := strconv.Unquote(value); err == nil {
224+ return unquoted
225+ }
226+ }
227+ return value
228+}
229+
219 func parseTheme(value string) (domain.ThemeMode, bool) {230 func parseTheme(value string) (domain.ThemeMode, bool) {
220 switch strings.ToLower(strings.TrimSpace(value)) {231 switch strings.ToLower(strings.TrimSpace(value)) {
221 case "auto", "":232 case "auto", "":
M internal/adapters/config/config_test.go +14 -0
@@ -105,6 +105,20 @@ func TestParse(t *testing.T) {
105105 Codes: map[string]string{},
106106 },
107107 },
108+ {
109+ name: "bar settings quoted commands unquote",
110+ input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Bar: domain.BarConfig{
114+ TopRight: "date +%H:%M",
115+ BottomRight: "",
116+ Interval: 5 * time.Second,
117+ },
118+ BindingEntries: []domain.ConfigEntry{},
119+ Codes: map[string]string{},
120+ },
121+ },
108122 {
109123 name: "bar settings empty commands disable anchors",
110124 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
@@ -105,6 +105,20 @@ func TestParse(t *testing.T) {
105 Codes: map[string]string{},105 Codes: map[string]string{},
106 },106 },
107 },107 },
108+ {
109+ name: "bar settings quoted commands unquote",
110+ input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Bar: domain.BarConfig{
114+ TopRight: "date +%H:%M",
115+ BottomRight: "",
116+ Interval: 5 * time.Second,
117+ },
118+ BindingEntries: []domain.ConfigEntry{},
119+ Codes: map[string]string{},
120+ },
121+ },
108 {122 {
109 name: "bar settings empty commands disable anchors",123 name: "bar settings empty commands disable anchors",
110 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",124 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
M internal/usecase/daemon/bar_refresh.go +18 -0
@@ -31,6 +31,7 @@ type barScriptState struct {
3131 runner barScriptExecutor
3232 outputs map[domain.SessionID]barScriptOutputs
3333 lastRefresh map[domain.SessionID]time.Time
34+ lastContext map[domain.SessionID]barScriptContext
3435 running map[domain.SessionID]bool
3536 }
3637
@@ -55,6 +56,9 @@ func (s *barScriptState) initLocked() {
5556 if s.lastRefresh == nil {
5657 s.lastRefresh = make(map[domain.SessionID]time.Time)
5758 }
59+ if s.lastContext == nil {
60+ s.lastContext = make(map[domain.SessionID]barScriptContext)
61+ }
5862 if s.running == nil {
5963 s.running = make(map[domain.SessionID]bool)
6064 }
@@ -100,10 +104,19 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
100104 tb.mu.Lock()
101105 ctx.Tab = tb.stableID
102106 ctx.Cols = tb.size.Cols
107+ pid := 0
103108 if p := tb.focusedPane(); p != nil {
104109 ctx.Pane = p.stableID
110+ if d != nil && d.procCwd != nil && p.pty != nil {
111+ pid = p.pty.Pid()
112+ }
105113 }
106114 tb.mu.Unlock()
115+ if pid != 0 && d != nil && d.procCwd != nil {
116+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
117+ ctx.PaneCWD = cwd
118+ }
119+ }
107120 return ctx, true
108121 }
109122
@@ -141,6 +154,7 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
141154 defer d.barScripts.mu.Unlock()
142155 delete(d.barScripts.outputs, id)
143156 delete(d.barScripts.lastRefresh, id)
157+ delete(d.barScripts.lastContext, id)
144158 delete(d.barScripts.running, id)
145159 }
146160
@@ -154,12 +168,16 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
154168 }
155169 d.barScripts.mu.Lock()
156170 d.barScripts.initLocked()
171+ if d.barScripts.lastContext[sess.id] != baseCtx {
172+ force = true
173+ }
157174 if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) {
158175 d.barScripts.mu.Unlock()
159176 return false
160177 }
161178 d.barScripts.running[sess.id] = true
162179 d.barScripts.lastRefresh[sess.id] = now
180+ d.barScripts.lastContext[sess.id] = baseCtx
163181 cfg := d.barScripts.cfg
164182 runner := d.barScripts.runner
165183 d.barScripts.mu.Unlock()
@@ -31,6 +31,7 @@ type barScriptState struct {
31 runner barScriptExecutor31 runner barScriptExecutor
32 outputs map[domain.SessionID]barScriptOutputs32 outputs map[domain.SessionID]barScriptOutputs
33 lastRefresh map[domain.SessionID]time.Time33 lastRefresh map[domain.SessionID]time.Time
34+ lastContext map[domain.SessionID]barScriptContext
34 running map[domain.SessionID]bool35 running map[domain.SessionID]bool
35 }36 }
36 37
@@ -55,6 +56,9 @@ func (s *barScriptState) initLocked() {
55 if s.lastRefresh == nil {56 if s.lastRefresh == nil {
56 s.lastRefresh = make(map[domain.SessionID]time.Time)57 s.lastRefresh = make(map[domain.SessionID]time.Time)
57 }58 }
59+ if s.lastContext == nil {
60+ s.lastContext = make(map[domain.SessionID]barScriptContext)
61+ }
58 if s.running == nil {62 if s.running == nil {
59 s.running = make(map[domain.SessionID]bool)63 s.running = make(map[domain.SessionID]bool)
60 }64 }
@@ -100,10 +104,19 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
100 tb.mu.Lock()104 tb.mu.Lock()
101 ctx.Tab = tb.stableID105 ctx.Tab = tb.stableID
102 ctx.Cols = tb.size.Cols106 ctx.Cols = tb.size.Cols
107+ pid := 0
103 if p := tb.focusedPane(); p != nil {108 if p := tb.focusedPane(); p != nil {
104 ctx.Pane = p.stableID109 ctx.Pane = p.stableID
110+ if d != nil && d.procCwd != nil && p.pty != nil {
111+ pid = p.pty.Pid()
112+ }
105 }113 }
106 tb.mu.Unlock()114 tb.mu.Unlock()
115+ if pid != 0 && d != nil && d.procCwd != nil {
116+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
117+ ctx.PaneCWD = cwd
118+ }
119+ }
107 return ctx, true120 return ctx, true
108 }121 }
109 122
@@ -141,6 +154,7 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
141 defer d.barScripts.mu.Unlock()154 defer d.barScripts.mu.Unlock()
142 delete(d.barScripts.outputs, id)155 delete(d.barScripts.outputs, id)
143 delete(d.barScripts.lastRefresh, id)156 delete(d.barScripts.lastRefresh, id)
157+ delete(d.barScripts.lastContext, id)
144 delete(d.barScripts.running, id)158 delete(d.barScripts.running, id)
145 }159 }
146 160
@@ -154,12 +168,16 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
154 }168 }
155 d.barScripts.mu.Lock()169 d.barScripts.mu.Lock()
156 d.barScripts.initLocked()170 d.barScripts.initLocked()
171+ if d.barScripts.lastContext[sess.id] != baseCtx {
172+ force = true
173+ }
157 if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) {174 if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) {
158 d.barScripts.mu.Unlock()175 d.barScripts.mu.Unlock()
159 return false176 return false
160 }177 }
161 d.barScripts.running[sess.id] = true178 d.barScripts.running[sess.id] = true
162 d.barScripts.lastRefresh[sess.id] = now179 d.barScripts.lastRefresh[sess.id] = now
180+ d.barScripts.lastContext[sess.id] = baseCtx
163 cfg := d.barScripts.cfg181 cfg := d.barScripts.cfg
164 runner := d.barScripts.runner182 runner := d.barScripts.runner
165 d.barScripts.mu.Unlock()183 d.barScripts.mu.Unlock()
M internal/usecase/daemon/bar_refresh_test.go +22 -1
@@ -69,6 +69,11 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
6969 sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
7070 active := sess.tabs[1].focusedPane()
7171 active.stableID = "pane-active"
72+ active.pty = newScriptPTY(nil)
73+ d.procCwd = func(pid int) (string, error) {
74+ require.Equal(t, 4242, pid)
75+ return "/pane-repo", nil
76+ }
7277
7378 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
7479 waitBarRefreshIdle(t, d)
@@ -77,7 +82,7 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
7782 require.Equal(t, "work", call.Session)
7883 require.Equal(t, "tab-active", call.Tab)
7984 require.Equal(t, "pane-active", call.Pane)
80- require.Equal(t, "/repo", call.PaneCWD)
85+ require.Equal(t, "/pane-repo", call.PaneCWD)
8186 require.Equal(t, 132, call.Cols)
8287 }
8388 require.Equal(t, "top-right", r.calls[0].Anchor)
@@ -93,6 +98,21 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
9398 require.Empty(t, r.calls)
9499 }
95100
101+func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
102+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
103+ d := newBarRefreshTestDaemon(r, 5*time.Second)
104+ sess := newBarRefreshTestSession()
105+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
106+
107+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
108+ waitBarRefreshIdle(t, d)
109+ sess.cwd = "/other"
110+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
111+ waitBarRefreshIdle(t, d)
112+ require.Len(t, r.calls, 4)
113+ require.Equal(t, "/other", r.calls[2].PaneCWD)
114+}
115+
96116 func TestBarScriptRefreshIsPerSession(t *testing.T) {
97117 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
98118 d := newBarRefreshTestDaemon(r, time.Second)
@@ -124,6 +144,7 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
124144 runner: r,
125145 outputs: make(map[domain.SessionID]barScriptOutputs),
126146 lastRefresh: make(map[domain.SessionID]time.Time),
147+ lastContext: make(map[domain.SessionID]barScriptContext),
127148 running: make(map[domain.SessionID]bool),
128149 }}
129150 return d
@@ -69,6 +69,11 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
69 sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}69 sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
70 active := sess.tabs[1].focusedPane()70 active := sess.tabs[1].focusedPane()
71 active.stableID = "pane-active"71 active.stableID = "pane-active"
72+ active.pty = newScriptPTY(nil)
73+ d.procCwd = func(pid int) (string, error) {
74+ require.Equal(t, 4242, pid)
75+ return "/pane-repo", nil
76+ }
72 77
73 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))78 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
74 waitBarRefreshIdle(t, d)79 waitBarRefreshIdle(t, d)
@@ -77,7 +82,7 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
77 require.Equal(t, "work", call.Session)82 require.Equal(t, "work", call.Session)
78 require.Equal(t, "tab-active", call.Tab)83 require.Equal(t, "tab-active", call.Tab)
79 require.Equal(t, "pane-active", call.Pane)84 require.Equal(t, "pane-active", call.Pane)
80- require.Equal(t, "/repo", call.PaneCWD)85+ require.Equal(t, "/pane-repo", call.PaneCWD)
81 require.Equal(t, 132, call.Cols)86 require.Equal(t, 132, call.Cols)
82 }87 }
83 require.Equal(t, "top-right", r.calls[0].Anchor)88 require.Equal(t, "top-right", r.calls[0].Anchor)
@@ -93,6 +98,21 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
93 require.Empty(t, r.calls)98 require.Empty(t, r.calls)
94 }99 }
95 100
101+func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
102+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
103+ d := newBarRefreshTestDaemon(r, 5*time.Second)
104+ sess := newBarRefreshTestSession()
105+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
106+
107+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
108+ waitBarRefreshIdle(t, d)
109+ sess.cwd = "/other"
110+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
111+ waitBarRefreshIdle(t, d)
112+ require.Len(t, r.calls, 4)
113+ require.Equal(t, "/other", r.calls[2].PaneCWD)
114+}
115+
96 func TestBarScriptRefreshIsPerSession(t *testing.T) {116 func TestBarScriptRefreshIsPerSession(t *testing.T) {
97 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}117 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
98 d := newBarRefreshTestDaemon(r, time.Second)118 d := newBarRefreshTestDaemon(r, time.Second)
@@ -124,6 +144,7 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
124 runner: r,144 runner: r,
125 outputs: make(map[domain.SessionID]barScriptOutputs),145 outputs: make(map[domain.SessionID]barScriptOutputs),
126 lastRefresh: make(map[domain.SessionID]time.Time),146 lastRefresh: make(map[domain.SessionID]time.Time),
147+ lastContext: make(map[domain.SessionID]barScriptContext),
127 running: make(map[domain.SessionID]bool),148 running: make(map[domain.SessionID]bool),
128 }}149 }}
129 return d150 return d
M internal/usecase/daemon/bar_script.go +50 -16
@@ -72,9 +72,8 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
7272 cmd.Env = scriptCtx.env(r.baseEnv)
7373 var stdout boundedBuffer
7474 stdout.limit = barScriptOutputLimit
75- var stderr bytes.Buffer
7675 cmd.Stdout = &stdout
77- cmd.Stderr = &stderr
76+ cmd.Stderr = io.Discard
7877
7978 err := cmd.Run()
8079 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
@@ -128,21 +127,17 @@ func stripANSIAndControls(s string) string {
128127 var out strings.Builder
129128 for i := 0; i < len(s); {
130129 r, size := utf8.DecodeRuneInString(s[i:])
131- if r == '\x1b' {
132- i += size
133- if i < len(s) && s[i] == '[' {
134- i++
135- for i < len(s) {
136- c := s[i]
137- i++
138- if c >= 0x40 && c <= 0x7e {
139- break
140- }
141- }
142- }
130+ switch {
131+ case r == '\x1b':
132+ i = skipEscapeSequence(s, i+size)
143133 continue
144- }
145- if r < 0x20 || r == 0x7f {
134+ case r == 0x9b:
135+ i = skipCSI(s, i+size)
136+ continue
137+ case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f:
138+ i = skipStringControl(s, i+size)
139+ continue
140+ case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
146141 i += size
147142 continue
148143 }
@@ -152,6 +147,45 @@ func stripANSIAndControls(s string) string {
152147 return out.String()
153148 }
154149
150+func skipEscapeSequence(s string, i int) int {
151+ if i >= len(s) {
152+ return i
153+ }
154+ switch s[i] {
155+ case '[':
156+ return skipCSI(s, i+1)
157+ case ']', 'P', '^', '_', 'X':
158+ return skipStringControl(s, i+1)
159+ default:
160+ return i + 1
161+ }
162+}
163+
164+func skipCSI(s string, i int) int {
165+ for i < len(s) {
166+ c := s[i]
167+ i++
168+ if c >= 0x40 && c <= 0x7e {
169+ break
170+ }
171+ }
172+ return i
173+}
174+
175+func skipStringControl(s string, i int) int {
176+ for i < len(s) {
177+ if s[i] == '\a' {
178+ return i + 1
179+ }
180+ if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
181+ return i + 2
182+ }
183+ _, size := utf8.DecodeRuneInString(s[i:])
184+ i += size
185+ }
186+ return i
187+}
188+
155189 func trimUTF8Bytes(s string, limit int) string {
156190 if len(s) <= limit {
157191 return s
@@ -72,9 +72,8 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS
72 cmd.Env = scriptCtx.env(r.baseEnv)72 cmd.Env = scriptCtx.env(r.baseEnv)
73 var stdout boundedBuffer73 var stdout boundedBuffer
74 stdout.limit = barScriptOutputLimit74 stdout.limit = barScriptOutputLimit
75- var stderr bytes.Buffer
76 cmd.Stdout = &stdout75 cmd.Stdout = &stdout
77- cmd.Stderr = &stderr76+ cmd.Stderr = io.Discard
78 77
79 err := cmd.Run()78 err := cmd.Run()
80 if errors.Is(ctx.Err(), context.DeadlineExceeded) {79 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
@@ -128,21 +127,17 @@ func stripANSIAndControls(s string) string {
128 var out strings.Builder127 var out strings.Builder
129 for i := 0; i < len(s); {128 for i := 0; i < len(s); {
130 r, size := utf8.DecodeRuneInString(s[i:])129 r, size := utf8.DecodeRuneInString(s[i:])
131- if r == '\x1b' {130+ switch {
132- i += size131+ case r == '\x1b':
133- if i < len(s) && s[i] == '[' {132+ i = skipEscapeSequence(s, i+size)
134- i++
135- for i < len(s) {
136- c := s[i]
137- i++
138- if c >= 0x40 && c <= 0x7e {
139- break
140- }
141- }
142- }
143 continue133 continue
144- }134+ case r == 0x9b:
145- if r < 0x20 || r == 0x7f {135+ i = skipCSI(s, i+size)
136+ continue
137+ case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f:
138+ i = skipStringControl(s, i+size)
139+ continue
140+ case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
146 i += size141 i += size
147 continue142 continue
148 }143 }
@@ -152,6 +147,45 @@ func stripANSIAndControls(s string) string {
152 return out.String()147 return out.String()
153 }148 }
154 149
150+func skipEscapeSequence(s string, i int) int {
151+ if i >= len(s) {
152+ return i
153+ }
154+ switch s[i] {
155+ case '[':
156+ return skipCSI(s, i+1)
157+ case ']', 'P', '^', '_', 'X':
158+ return skipStringControl(s, i+1)
159+ default:
160+ return i + 1
161+ }
162+}
163+
164+func skipCSI(s string, i int) int {
165+ for i < len(s) {
166+ c := s[i]
167+ i++
168+ if c >= 0x40 && c <= 0x7e {
169+ break
170+ }
171+ }
172+ return i
173+}
174+
175+func skipStringControl(s string, i int) int {
176+ for i < len(s) {
177+ if s[i] == '\a' {
178+ return i + 1
179+ }
180+ if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
181+ return i + 2
182+ }
183+ _, size := utf8.DecodeRuneInString(s[i:])
184+ i += size
185+ }
186+ return i
187+}
188+
155 func trimUTF8Bytes(s string, limit int) string {189 func trimUTF8Bytes(s string, limit int) string {
156 if len(s) <= limit {190 if len(s) <= limit {
157 return s191 return s
M internal/usecase/daemon/bar_script_test.go +2 -0
@@ -19,6 +19,8 @@ func TestBarScriptSanitizeOutput(t *testing.T) {
1919 {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
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"},
22+ {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},
23+ {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
2224 {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
2325 {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
2426 {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
@@ -19,6 +19,8 @@ func TestBarScriptSanitizeOutput(t *testing.T) {
19 {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},19 {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
20 {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},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"},
23+ {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
22 {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},24 {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
23 {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},25 {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
24 {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},26 {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
M internal/usecase/daemon/config.go +1 -0
@@ -33,6 +33,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
3333 d.barScripts.cfg = barConfigFromDomain(cfg.Bar)
3434 d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
3535 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
36+ d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
3637 d.barScripts.running = make(map[domain.SessionID]bool)
3738 d.barScripts.mu.Unlock()
3839 }
@@ -33,6 +33,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
33 d.barScripts.cfg = barConfigFromDomain(cfg.Bar)33 d.barScripts.cfg = barConfigFromDomain(cfg.Bar)
34 d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)34 d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
35 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)35 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
36+ d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
36 d.barScripts.running = make(map[domain.SessionID]bool)37 d.barScripts.running = make(map[domain.SessionID]bool)
37 d.barScripts.mu.Unlock()38 d.barScripts.mu.Unlock()
38 }39 }
M internal/usecase/daemon/daemon.go +1 -0
@@ -261,6 +261,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
261261 runner: barScriptRunner{baseEnv: os.Environ()},
262262 outputs: make(map[domain.SessionID]barScriptOutputs),
263263 lastRefresh: make(map[domain.SessionID]time.Time),
264+ lastContext: make(map[domain.SessionID]barScriptContext),
264265 running: make(map[domain.SessionID]bool),
265266 },
266267 }
@@ -261,6 +261,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
261 runner: barScriptRunner{baseEnv: os.Environ()},261 runner: barScriptRunner{baseEnv: os.Environ()},
262 outputs: make(map[domain.SessionID]barScriptOutputs),262 outputs: make(map[domain.SessionID]barScriptOutputs),
263 lastRefresh: make(map[domain.SessionID]time.Time),263 lastRefresh: make(map[domain.SessionID]time.Time),
264+ lastContext: make(map[domain.SessionID]barScriptContext),
264 running: make(map[domain.SessionID]bool),265 running: make(map[domain.SessionID]bool),
265 },266 },
266 }267 }
M internal/usecase/daemon/daemon_test.go +4 -0
@@ -306,6 +306,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
306306 func rowText(row []renderer.Cell) string {
307307 runes := make([]rune, len(row))
308308 for i, c := range row {
309+ if c.Continuation {
310+ runes[i] = ' '
311+ continue
312+ }
309313 runes[i] = c.Rune
310314 }
311315 return string(runes)
@@ -306,6 +306,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
306 func rowText(row []renderer.Cell) string {306 func rowText(row []renderer.Cell) string {
307 runes := make([]rune, len(row))307 runes := make([]rune, len(row))
308 for i, c := range row {308 for i, c := range row {
309+ if c.Continuation {
310+ runes[i] = ' '
311+ continue
312+ }
309 runes[i] = c.Rune313 runes[i] = c.Rune
310 }314 }
311 return string(runes)315 return string(runes)
M internal/usecase/daemon/status.go +20 -3
@@ -110,13 +110,22 @@ func composeBottomRightText(scriptText, copyFeedback string) string {
110110 }
111111
112112 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
113- if text == "" || len([]rune(text))+1+reservedLeft > len(row) {
113+ textWidth := statusTextWidth(text)
114+ if text == "" || textWidth+1+reservedLeft > len(row) {
114115 return
115116 }
116- x := len(row) - len([]rune(text)) - 1
117+ x := len(row) - textWidth - 1
117118 writeStatusText(row, &x, " "+text, style)
118119 }
119120
121+func statusTextWidth(text string) int {
122+ width := 0
123+ for _, r := range text {
124+ width += renderer.RuneWidth(r)
125+ }
126+ return width
127+}
128+
120129 func clearStatusRow(row []renderer.Cell) {
121130 for i := range row {
122131 row[i] = renderer.BlankCell()
@@ -303,10 +312,18 @@ func writeBell(row []renderer.Cell, x *int, frame int) {
303312
304313 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
305314 for _, r := range text {
306- if *x >= len(row) {
315+ width := renderer.RuneWidth(r)
316+ if width == 0 {
317+ continue
318+ }
319+ if *x >= len(row) || *x+width > len(row) {
307320 return
308321 }
309322 row[*x] = renderer.Cell{Rune: r, Style: style}
310323 (*x)++
324+ if width == 2 {
325+ row[*x] = renderer.Cell{Style: style, Continuation: true}
326+ (*x)++
327+ }
311328 }
312329 }
@@ -110,13 +110,22 @@ func composeBottomRightText(scriptText, copyFeedback string) string {
110 }110 }
111 111
112 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {112 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
113- if text == "" || len([]rune(text))+1+reservedLeft > len(row) {113+ textWidth := statusTextWidth(text)
114+ if text == "" || textWidth+1+reservedLeft > len(row) {
114 return115 return
115 }116 }
116- x := len(row) - len([]rune(text)) - 1117+ x := len(row) - textWidth - 1
117 writeStatusText(row, &x, " "+text, style)118 writeStatusText(row, &x, " "+text, style)
118 }119 }
119 120
121+func statusTextWidth(text string) int {
122+ width := 0
123+ for _, r := range text {
124+ width += renderer.RuneWidth(r)
125+ }
126+ return width
127+}
128+
120 func clearStatusRow(row []renderer.Cell) {129 func clearStatusRow(row []renderer.Cell) {
121 for i := range row {130 for i := range row {
122 row[i] = renderer.BlankCell()131 row[i] = renderer.BlankCell()
@@ -303,10 +312,18 @@ func writeBell(row []renderer.Cell, x *int, frame int) {
303 312
304 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {313 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
305 for _, r := range text {314 for _, r := range text {
306- if *x >= len(row) {315+ width := renderer.RuneWidth(r)
316+ if width == 0 {
317+ continue
318+ }
319+ if *x >= len(row) || *x+width > len(row) {
307 return320 return
308 }321 }
309 row[*x] = renderer.Cell{Rune: r, Style: style}322 row[*x] = renderer.Cell{Rune: r, Style: style}
310 (*x)++323 (*x)++
324+ if width == 2 {
325+ row[*x] = renderer.Cell{Style: style, Continuation: true}
326+ (*x)++
327+ }
311 }328 }
312 }329 }
M internal/usecase/daemon/status_test.go +10 -0
@@ -416,6 +416,16 @@ func TestTopBarRightAnchorHidesOnOverlap(t *testing.T) {
416416 require.Equal(t, " 1 2 ", rowText(row))
417417 }
418418
419+func TestTopBarRightAnchorUsesDisplayWidth(t *testing.T) {
420+ row := make([]renderer.Cell, 12)
421+ status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}}
422+
423+ drawTopBarSnapshot(row, status, 0, "界a", resolveThemeStyles(nil))
424+
425+ require.Equal(t, " 1 界 a", rowText(row))
426+ require.True(t, row[10].Continuation)
427+}
428+
419429 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
420430 p, releasePTY := newBlockingPTY(t)
421431 _, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -416,6 +416,16 @@ func TestTopBarRightAnchorHidesOnOverlap(t *testing.T) {
416 require.Equal(t, " 1 2 ", rowText(row))416 require.Equal(t, " 1 2 ", rowText(row))
417 }417 }
418 418
419+func TestTopBarRightAnchorUsesDisplayWidth(t *testing.T) {
420+ row := make([]renderer.Cell, 12)
421+ status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}}
422+
423+ drawTopBarSnapshot(row, status, 0, "界a", resolveThemeStyles(nil))
424+
425+ require.Equal(t, " 1 界 a", rowText(row))
426+ require.True(t, row[10].Continuation)
427+}
428+
419 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {429 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
420 p, releasePTY := newBlockingPTY(t)430 p, releasePTY := newBlockingPTY(t)
421 _, sess, _, _ := newManualSessionWithPTYs(t, p)431 _, sess, _, _ := newManualSessionWithPTYs(t, p)

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