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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +155 -22
M internal/adapters/config/config.go +13 -2
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "io" | |
| 9 | 9 | "os" | |
| 10 | 10 | "regexp" | |
| 11 | + | "strconv" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| @@ -64,9 +65,9 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 64 | 65 | } | |
| 65 | 66 | cfg.Theme = mode | |
| 66 | 67 | case key == "bar.top-right": | |
| 67 | - | cfg.Bar.TopRight = value | |
| 68 | + | cfg.Bar.TopRight = parseBarCommand(value) | |
| 68 | 69 | case key == "bar.bottom-right": | |
| 69 | - | cfg.Bar.BottomRight = value | |
| 70 | + | cfg.Bar.BottomRight = parseBarCommand(value) | |
| 70 | 71 | case key == "bar.interval": | |
| 71 | 72 | interval, err := time.ParseDuration(value) | |
| 72 | 73 | if err != nil { | |
| @@ -216,6 +217,16 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) { | |||
| 216 | 217 | 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 | 230 | func parseTheme(value string) (domain.ThemeMode, bool) { | |
| 220 | 231 | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 221 | 232 | 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 = mode | 66 | cfg.Theme = mode | ||
| 66 | case key == "bar.top-right": | 67 | case key == "bar.top-right": | ||
| 67 | - | cfg.Bar.TopRight = value | 68 | + | cfg.Bar.TopRight = parseBarCommand(value) |
| 68 | case key == "bar.bottom-right": | 69 | case key == "bar.bottom-right": | ||
| 69 | - | cfg.Bar.BottomRight = value | 70 | + | 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, warnings | 217 | 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) { | |||
| 105 | 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 | 123 | name: "bar settings empty commands disable anchors", | |
| 110 | 124 | 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 { | |||
| 31 | 31 | runner barScriptExecutor | |
| 32 | 32 | outputs map[domain.SessionID]barScriptOutputs | |
| 33 | 33 | lastRefresh map[domain.SessionID]time.Time | |
| 34 | + | lastContext map[domain.SessionID]barScriptContext | |
| 34 | 35 | running map[domain.SessionID]bool | |
| 35 | 36 | } | |
| 36 | 37 | ||
| @@ -55,6 +56,9 @@ func (s *barScriptState) initLocked() { | |||
| 55 | 56 | if s.lastRefresh == nil { | |
| 56 | 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 | 62 | if s.running == nil { | |
| 59 | 63 | s.running = make(map[domain.SessionID]bool) | |
| 60 | 64 | } | |
| @@ -100,10 +104,19 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||
| 100 | 104 | tb.mu.Lock() | |
| 101 | 105 | ctx.Tab = tb.stableID | |
| 102 | 106 | ctx.Cols = tb.size.Cols | |
| 107 | + | pid := 0 | |
| 103 | 108 | if p := tb.focusedPane(); p != nil { | |
| 104 | 109 | ctx.Pane = p.stableID | |
| 110 | + | if d != nil && d.procCwd != nil && p.pty != nil { | |
| 111 | + | pid = p.pty.Pid() | |
| 112 | + | } | |
| 105 | 113 | } | |
| 106 | 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 | 120 | return ctx, true | |
| 108 | 121 | } | |
| 109 | 122 | ||
| @@ -141,6 +154,7 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |||
| 141 | 154 | defer d.barScripts.mu.Unlock() | |
| 142 | 155 | delete(d.barScripts.outputs, id) | |
| 143 | 156 | delete(d.barScripts.lastRefresh, id) | |
| 157 | + | delete(d.barScripts.lastContext, id) | |
| 144 | 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 | 169 | d.barScripts.mu.Lock() | |
| 156 | 170 | d.barScripts.initLocked() | |
| 171 | + | if d.barScripts.lastContext[sess.id] != baseCtx { | |
| 172 | + | force = true | |
| 173 | + | } | |
| 157 | 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 | 175 | d.barScripts.mu.Unlock() | |
| 159 | 176 | return false | |
| 160 | 177 | } | |
| 161 | 178 | d.barScripts.running[sess.id] = true | |
| 162 | 179 | d.barScripts.lastRefresh[sess.id] = now | |
| 180 | + | d.barScripts.lastContext[sess.id] = baseCtx | |
| 163 | 181 | cfg := d.barScripts.cfg | |
| 164 | 182 | runner := d.barScripts.runner | |
| 165 | 183 | d.barScripts.mu.Unlock() | |
| @@ -31,6 +31,7 @@ type barScriptState struct { | |||||
| 31 | runner barScriptExecutor | 31 | runner barScriptExecutor | ||
| 32 | outputs map[domain.SessionID]barScriptOutputs | 32 | outputs map[domain.SessionID]barScriptOutputs | ||
| 33 | lastRefresh map[domain.SessionID]time.Time | 33 | lastRefresh map[domain.SessionID]time.Time | ||
| 34 | + | lastContext map[domain.SessionID]barScriptContext | |||
| 34 | running map[domain.SessionID]bool | 35 | 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.stableID | 105 | ctx.Tab = tb.stableID | ||
| 102 | ctx.Cols = tb.size.Cols | 106 | 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.stableID | 109 | 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, true | 120 | 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 false | 176 | return false | ||
| 160 | } | 177 | } | ||
| 161 | d.barScripts.running[sess.id] = true | 178 | d.barScripts.running[sess.id] = true | ||
| 162 | d.barScripts.lastRefresh[sess.id] = now | 179 | d.barScripts.lastRefresh[sess.id] = now | ||
| 180 | + | d.barScripts.lastContext[sess.id] = baseCtx | |||
| 163 | cfg := d.barScripts.cfg | 181 | cfg := d.barScripts.cfg | ||
| 164 | runner := d.barScripts.runner | 182 | 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) { | |||
| 69 | 69 | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | |
| 70 | 70 | active := sess.tabs[1].focusedPane() | |
| 71 | 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 | 78 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 74 | 79 | waitBarRefreshIdle(t, d) | |
| @@ -77,7 +82,7 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |||
| 77 | 82 | require.Equal(t, "work", call.Session) | |
| 78 | 83 | require.Equal(t, "tab-active", call.Tab) | |
| 79 | 84 | require.Equal(t, "pane-active", call.Pane) | |
| 80 | - | require.Equal(t, "/repo", call.PaneCWD) | |
| 85 | + | require.Equal(t, "/pane-repo", call.PaneCWD) | |
| 81 | 86 | require.Equal(t, 132, call.Cols) | |
| 82 | 87 | } | |
| 83 | 88 | require.Equal(t, "top-right", r.calls[0].Anchor) | |
| @@ -93,6 +98,21 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) { | |||
| 93 | 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 | 116 | func TestBarScriptRefreshIsPerSession(t *testing.T) { | |
| 97 | 117 | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | |
| 98 | 118 | d := newBarRefreshTestDaemon(r, time.Second) | |
| @@ -124,6 +144,7 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |||
| 124 | 144 | runner: r, | |
| 125 | 145 | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| 126 | 146 | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 147 | + | lastContext: make(map[domain.SessionID]barScriptContext), | |
| 127 | 148 | running: make(map[domain.SessionID]bool), | |
| 128 | 149 | }} | |
| 129 | 150 | 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 d | 150 | 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 | |||
| 72 | 72 | cmd.Env = scriptCtx.env(r.baseEnv) | |
| 73 | 73 | var stdout boundedBuffer | |
| 74 | 74 | stdout.limit = barScriptOutputLimit | |
| 75 | - | var stderr bytes.Buffer | |
| 76 | 75 | cmd.Stdout = &stdout | |
| 77 | - | cmd.Stderr = &stderr | |
| 76 | + | cmd.Stderr = io.Discard | |
| 78 | 77 | ||
| 79 | 78 | err := cmd.Run() | |
| 80 | 79 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | |
| @@ -128,21 +127,17 @@ func stripANSIAndControls(s string) string { | |||
| 128 | 127 | var out strings.Builder | |
| 129 | 128 | for i := 0; i < len(s); { | |
| 130 | 129 | 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) | |
| 143 | 133 | 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): | |
| 146 | 141 | i += size | |
| 147 | 142 | continue | |
| 148 | 143 | } | |
| @@ -152,6 +147,45 @@ func stripANSIAndControls(s string) string { | |||
| 152 | 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 | 189 | func trimUTF8Bytes(s string, limit int) string { | |
| 156 | 190 | if len(s) <= limit { | |
| 157 | 191 | 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 boundedBuffer | 73 | var stdout boundedBuffer | ||
| 74 | stdout.limit = barScriptOutputLimit | 74 | stdout.limit = barScriptOutputLimit | ||
| 75 | - | var stderr bytes.Buffer | |||
| 76 | cmd.Stdout = &stdout | 75 | cmd.Stdout = &stdout | ||
| 77 | - | cmd.Stderr = &stderr | 76 | + | 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.Builder | 127 | 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 += size | 131 | + | 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 | continue | 133 | 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 += size | 141 | i += size | ||
| 147 | continue | 142 | 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 s | 191 | return s | ||
M internal/usecase/daemon/bar_script_test.go +2 -0
| @@ -19,6 +19,8 @@ func TestBarScriptSanitizeOutput(t *testing.T) { | |||
| 19 | 19 | {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"}, | |
| 20 | 20 | {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"}, | |
| 21 | 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 | 24 | {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"}, | |
| 23 | 25 | {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)}, | |
| 24 | 26 | {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) { | |||
| 33 | 33 | d.barScripts.cfg = barConfigFromDomain(cfg.Bar) | |
| 34 | 34 | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | |
| 35 | 35 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 36 | + | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |
| 36 | 37 | d.barScripts.running = make(map[domain.SessionID]bool) | |
| 37 | 38 | d.barScripts.mu.Unlock() | |
| 38 | 39 | } | |
| @@ -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 | |||
| 261 | 261 | runner: barScriptRunner{baseEnv: os.Environ()}, | |
| 262 | 262 | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| 263 | 263 | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 264 | + | lastContext: make(map[domain.SessionID]barScriptContext), | |
| 264 | 265 | running: make(map[domain.SessionID]bool), | |
| 265 | 266 | }, | |
| 266 | 267 | } | |
| @@ -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{} { | |||
| 306 | 306 | func rowText(row []renderer.Cell) string { | |
| 307 | 307 | runes := make([]rune, len(row)) | |
| 308 | 308 | for i, c := range row { | |
| 309 | + | if c.Continuation { | |
| 310 | + | runes[i] = ' ' | |
| 311 | + | continue | |
| 312 | + | } | |
| 309 | 313 | runes[i] = c.Rune | |
| 310 | 314 | } | |
| 311 | 315 | 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.Rune | 313 | 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 { | |||
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | 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 | 115 | return | |
| 115 | 116 | } | |
| 116 | - | x := len(row) - len([]rune(text)) - 1 | |
| 117 | + | x := len(row) - textWidth - 1 | |
| 117 | 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 | 129 | func clearStatusRow(row []renderer.Cell) { | |
| 121 | 130 | for i := range row { | |
| 122 | 131 | row[i] = renderer.BlankCell() | |
| @@ -303,10 +312,18 @@ func writeBell(row []renderer.Cell, x *int, frame int) { | |||
| 303 | 312 | ||
| 304 | 313 | func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) { | |
| 305 | 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 | 320 | return | |
| 308 | 321 | } | |
| 309 | 322 | row[*x] = renderer.Cell{Rune: r, Style: style} | |
| 310 | 323 | (*x)++ | |
| 324 | + | if width == 2 { | |
| 325 | + | row[*x] = renderer.Cell{Style: style, Continuation: true} | |
| 326 | + | (*x)++ | |
| 327 | + | } | |
| 311 | 328 | } | |
| 312 | 329 | } | |
| @@ -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 | return | 115 | return | ||
| 115 | } | 116 | } | ||
| 116 | - | x := len(row) - len([]rune(text)) - 1 | 117 | + | 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 | return | 320 | 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) { | |||
| 416 | 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 | 429 | func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |
| 420 | 430 | p, releasePTY := newBlockingPTY(t) | |
| 421 | 431 | _, 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) | ||