detached 3a57f9d

fix(daemon): address attention review findings

commit
3a57f9da6dce2ec32e7350292367e43cf184617d
author
brice · · 2026-07-03 14:22 UTC
committer
brice · · 2026-07-03 15:32 UTC
parent
5b2d745
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +63 -20
M internal/usecase/daemon/attention.go +2 -14
@@ -55,20 +55,8 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
5555 }
5656
5757 func oldestAttentionTab(sess *session) (int, bool) {
58- sess.mu.Lock()
59- defer sess.mu.Unlock()
60- idx := -1
61- var oldest time.Time
62- for i, tb := range sess.tabs {
63- if !tb.attention {
64- continue
65- }
66- if idx == -1 || tb.attentionAt.Before(oldest) {
67- idx = i
68- oldest = tb.attentionAt
69- }
70- }
71- return idx, idx != -1
58+ idx, _, ok := oldestAttentionTabWithTime(sess)
59+ return idx, ok
7260 }
7361
7462 type attentionTarget struct {
@@ -55,20 +55,8 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
55 }55 }
56 56
57 func oldestAttentionTab(sess *session) (int, bool) {57 func oldestAttentionTab(sess *session) (int, bool) {
58- sess.mu.Lock()58+ idx, _, ok := oldestAttentionTabWithTime(sess)
59- defer sess.mu.Unlock()59+ return idx, ok
60- idx := -1
61- var oldest time.Time
62- for i, tb := range sess.tabs {
63- if !tb.attention {
64- continue
65- }
66- if idx == -1 || tb.attentionAt.Before(oldest) {
67- idx = i
68- oldest = tb.attentionAt
69- }
70- }
71- return idx, idx != -1
72 }60 }
73 61
74 type attentionTarget struct {62 type attentionTarget struct {
M internal/usecase/daemon/copymode.go +10 -2
@@ -307,8 +307,16 @@ func isCopyEscapePrefix(data []byte) bool {
307307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
308308 }
309309
310-func composeCopyClientFrame(mode *scopy.Mode, tb *tab, copyStatusStyle, selectionStyle renderer.Style) (renderer.Frame, []renderer.Damage) {
310+func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
311+ styles := newThemeStyles(bars.theme)
311312 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
312- frame := mode.Render(snap, copyStatusStyle, selectionStyle)
313+ copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
314+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
315+ frame := renderer.NewFrame(width, screenRows+2)
316+ drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)
317+ for y := range screenRows {
318+ copy(frame.Row(y+1), copyFrame.Row(y))
319+ }
320+ copy(frame.Row(screenRows+1), copyFrame.Row(screenRows))
313321 return frame, []renderer.Damage{renderer.FullRedraw()}
314322 }
@@ -307,8 +307,16 @@ func isCopyEscapePrefix(data []byte) bool {
307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
308 }308 }
309 309
310-func composeCopyClientFrame(mode *scopy.Mode, tb *tab, copyStatusStyle, selectionStyle renderer.Style) (renderer.Frame, []renderer.Damage) {310+func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
311+ styles := newThemeStyles(bars.theme)
311 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)312 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
312- frame := mode.Render(snap, copyStatusStyle, selectionStyle)313+ copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
314+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
315+ frame := renderer.NewFrame(width, screenRows+2)
316+ drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)
317+ for y := range screenRows {
318+ copy(frame.Row(y+1), copyFrame.Row(y))
319+ }
320+ copy(frame.Row(screenRows+1), copyFrame.Row(screenRows))
313 return frame, []renderer.Damage{renderer.FullRedraw()}321 return frame, []renderer.Damage{renderer.FullRedraw()}
314 }322 }
M internal/usecase/daemon/copymode_test.go +21 -0
@@ -18,6 +18,7 @@ import (
1818 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1919 scopy "github.com/bnema/vev/internal/usecase/copy"
2020 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
2122 )
2223
2324 // --- test doubles -----------------------------------------------------------
@@ -25,6 +26,26 @@ import (
2526 // stubClock returns timers whose channel never fires, so a scheduler under it
2627 // blocks in its debounce loop until the session context is cancelled. Used by
2728
29+func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
30+ p, release := newBlockingPTY(t)
31+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
32+ defer release()
33+ sess.name = "work"
34+ tb := sess.activeTab()
35+ tb.screen = vt.NewScreen(12, 3)
36+ tb.screen.Write([]byte("live"))
37+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
38+ mode := scopy.NewMode(snap)
39+
40+ frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
41+
42+ require.Equal(t, 12, frame.Width)
43+ require.Equal(t, 5, frame.Height)
44+ require.Equal(t, " 1 ", rowText(frame.Row(0)))
45+ require.Contains(t, rowText(frame.Row(4)), "[VISUAL]")
46+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
47+}
48+
2849 func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
2950 writes := make(chan []byte, 1)
3051 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -18,6 +18,7 @@ import (
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
20 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
21 )22 )
22 23
23 // --- test doubles -----------------------------------------------------------24 // --- test doubles -----------------------------------------------------------
@@ -25,6 +26,26 @@ import (
25 // stubClock returns timers whose channel never fires, so a scheduler under it26 // stubClock returns timers whose channel never fires, so a scheduler under it
26 // blocks in its debounce loop until the session context is cancelled. Used by27 // blocks in its debounce loop until the session context is cancelled. Used by
27 28
29+func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
30+ p, release := newBlockingPTY(t)
31+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
32+ defer release()
33+ sess.name = "work"
34+ tb := sess.activeTab()
35+ tb.screen = vt.NewScreen(12, 3)
36+ tb.screen.Write([]byte("live"))
37+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
38+ mode := scopy.NewMode(snap)
39+
40+ frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
41+
42+ require.Equal(t, 12, frame.Width)
43+ require.Equal(t, 5, frame.Height)
44+ require.Equal(t, " 1 ", rowText(frame.Row(0)))
45+ require.Contains(t, rowText(frame.Row(4)), "[VISUAL]")
46+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
47+}
48+
28 func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {49 func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
29 writes := make(chan []byte, 1)50 writes := make(chan []byte, 1)
30 p, _ := newBlockingPTYWithWrites(t, writes)51 p, _ := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/input_test.go +2 -0
@@ -222,6 +222,8 @@ func TestSGRRowOffset(t *testing.T) {
222222 {name: "leaves malformed fields unchanged", raw: []byte("\x1b[<0;2M"), delta: -1, want: []byte("\x1b[<0;2M")},
223223 {name: "leaves non numeric row unchanged", raw: []byte("\x1b[<0;2;xM"), delta: -1, want: []byte("\x1b[<0;2;xM")},
224224 {name: "leaves invalid shifted row unchanged", raw: []byte("\x1b[<0;2;1M"), delta: -1, want: []byte("\x1b[<0;2;1M")},
225+ {name: "handles digit width increase", raw: []byte("\x1b[<0;2;9M"), delta: 1, want: []byte("\x1b[<0;2;10M")},
226+ {name: "handles digit width decrease", raw: []byte("\x1b[<0;2;10m"), delta: -1, want: []byte("\x1b[<0;2;9m")},
225227 }
226228
227229 for _, tc := range cases {
@@ -222,6 +222,8 @@ func TestSGRRowOffset(t *testing.T) {
222 {name: "leaves malformed fields unchanged", raw: []byte("\x1b[<0;2M"), delta: -1, want: []byte("\x1b[<0;2M")},222 {name: "leaves malformed fields unchanged", raw: []byte("\x1b[<0;2M"), delta: -1, want: []byte("\x1b[<0;2M")},
223 {name: "leaves non numeric row unchanged", raw: []byte("\x1b[<0;2;xM"), delta: -1, want: []byte("\x1b[<0;2;xM")},223 {name: "leaves non numeric row unchanged", raw: []byte("\x1b[<0;2;xM"), delta: -1, want: []byte("\x1b[<0;2;xM")},
224 {name: "leaves invalid shifted row unchanged", raw: []byte("\x1b[<0;2;1M"), delta: -1, want: []byte("\x1b[<0;2;1M")},224 {name: "leaves invalid shifted row unchanged", raw: []byte("\x1b[<0;2;1M"), delta: -1, want: []byte("\x1b[<0;2;1M")},
225+ {name: "handles digit width increase", raw: []byte("\x1b[<0;2;9M"), delta: 1, want: []byte("\x1b[<0;2;10M")},
226+ {name: "handles digit width decrease", raw: []byte("\x1b[<0;2;10m"), delta: -1, want: []byte("\x1b[<0;2;9m")},
225 }227 }
226 228
227 for _, tc := range cases {229 for _, tc := range cases {
M internal/usecase/daemon/render.go +1 -1
@@ -299,7 +299,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
299299 }
300300 frame, damage := composeClientFrameWithState(bars, tb, reset, &ac.bars)
301301 if copyActive {
302- frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection)
302+ frame, damage = composeCopyClientFrame(copyMode, tb, bars)
303303 }
304304 if pickerActive {
305305 if previewTab == tb {
@@ -299,7 +299,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
299 }299 }
300 frame, damage := composeClientFrameWithState(bars, tb, reset, &ac.bars)300 frame, damage := composeClientFrameWithState(bars, tb, reset, &ac.bars)
301 if copyActive {301 if copyActive {
302- frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection)302+ frame, damage = composeCopyClientFrame(copyMode, tb, bars)
303 }303 }
304 if pickerActive {304 if pickerActive {
305 if previewTab == tb {305 if previewTab == tb {
M internal/usecase/daemon/status.go +5 -3
@@ -169,16 +169,18 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
169169 continue
170170 }
171171 sess.mu.Lock()
172- oldest := time.Time{}
172+ var oldest time.Time
173+ found := false
173174 for _, tb := range sess.tabs {
174175 if !tb.attention {
175176 continue
176177 }
177- if oldest.IsZero() || tb.attentionAt.Before(oldest) {
178+ if !found || tb.attentionAt.Before(oldest) {
178179 oldest = tb.attentionAt
180+ found = true
179181 }
180182 }
181- if !oldest.IsZero() {
183+ if found {
182184 attention = append(attention, attentionSession{name: sess.name, at: oldest})
183185 }
184186 sess.mu.Unlock()
@@ -169,16 +169,18 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
169 continue169 continue
170 }170 }
171 sess.mu.Lock()171 sess.mu.Lock()
172- oldest := time.Time{}172+ var oldest time.Time
173+ found := false
173 for _, tb := range sess.tabs {174 for _, tb := range sess.tabs {
174 if !tb.attention {175 if !tb.attention {
175 continue176 continue
176 }177 }
177- if oldest.IsZero() || tb.attentionAt.Before(oldest) {178+ if !found || tb.attentionAt.Before(oldest) {
178 oldest = tb.attentionAt179 oldest = tb.attentionAt
180+ found = true
179 }181 }
180 }182 }
181- if !oldest.IsZero() {183+ if found {
182 attention = append(attention, attentionSession{name: sess.name, at: oldest})184 attention = append(attention, attentionSession{name: sess.name, at: oldest})
183 }185 }
184 sess.mu.Unlock()186 sess.mu.Unlock()
M internal/usecase/daemon/status_test.go +11 -0
@@ -314,6 +314,17 @@ func TestStatusBarRendersOtherAttentionOldestFirstAndCollapses(t *testing.T) {
314314 require.Equal(t, " current ok", rowText(narrow))
315315 }
316316
317+func TestBarStateForIncludesZeroTimeAttention(t *testing.T) {
318+ p, releasePTY := newBlockingPTY(t)
319+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
320+ defer releasePTY()
321+ d.sessions["zero"] = &session{id: "zero", name: "zero", tabs: []*tab{{attention: true}}}
322+
323+ state := d.barStateFor(sess, "")
324+
325+ require.Equal(t, []string{"zero"}, state.otherAttention)
326+}
327+
317328 func TestBarStateForExcludesCurrentSession(t *testing.T) {
318329 p, releasePTY := newBlockingPTY(t)
319330 d, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -314,6 +314,17 @@ func TestStatusBarRendersOtherAttentionOldestFirstAndCollapses(t *testing.T) {
314 require.Equal(t, " current ok", rowText(narrow))314 require.Equal(t, " current ok", rowText(narrow))
315 }315 }
316 316
317+func TestBarStateForIncludesZeroTimeAttention(t *testing.T) {
318+ p, releasePTY := newBlockingPTY(t)
319+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
320+ defer releasePTY()
321+ d.sessions["zero"] = &session{id: "zero", name: "zero", tabs: []*tab{{attention: true}}}
322+
323+ state := d.barStateFor(sess, "")
324+
325+ require.Equal(t, []string{"zero"}, state.otherAttention)
326+}
327+
317 func TestBarStateForExcludesCurrentSession(t *testing.T) {328 func TestBarStateForExcludesCurrentSession(t *testing.T) {
318 p, releasePTY := newBlockingPTY(t)329 p, releasePTY := newBlockingPTY(t)
319 d, sess, _, _ := newManualSessionWithPTYs(t, p)330 d, sess, _, _ := newManualSessionWithPTYs(t, p)
M pkg/vt/screen.go +3 -0
@@ -629,6 +629,9 @@ func (s *Screen) handleOSC(payload []byte) {
629629 p := string(payload)
630630 switch {
631631 case strings.HasPrefix(p, "9;"):
632+ if strings.HasPrefix(p, "9;4;") {
633+ return
634+ }
632635 s.OnNotify("", p[len("9;"):])
633636 case strings.HasPrefix(p, "777;"):
634637 parts := strings.SplitN(p[len("777;"):], ";", 3)
@@ -629,6 +629,9 @@ func (s *Screen) handleOSC(payload []byte) {
629 p := string(payload)629 p := string(payload)
630 switch {630 switch {
631 case strings.HasPrefix(p, "9;"):631 case strings.HasPrefix(p, "9;"):
632+ if strings.HasPrefix(p, "9;4;") {
633+ return
634+ }
632 s.OnNotify("", p[len("9;"):])635 s.OnNotify("", p[len("9;"):])
633 case strings.HasPrefix(p, "777;"):636 case strings.HasPrefix(p, "777;"):
634 parts := strings.SplitN(p[len("777;"):], ";", 3)637 parts := strings.SplitN(p[len("777;"):], ";", 3)
M pkg/vt/screen_test.go +8 -0
@@ -120,6 +120,14 @@ func TestOnNotifyOSC9(t *testing.T) {
120120 require.Equal(t, "agent done", gotBody)
121121 }
122122
123+func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
124+ s := NewScreen(10, 2)
125+ calls := 0
126+ s.OnNotify = func(string, string) { calls++ }
127+ s.Write([]byte("\x1b]9;4;1;50\x07"))
128+ require.Equal(t, 0, calls)
129+}
130+
123131 func TestOnNotifyOSC777STTerminated(t *testing.T) {
124132 s := NewScreen(10, 2)
125133 var gotTitle, gotBody string
@@ -120,6 +120,14 @@ func TestOnNotifyOSC9(t *testing.T) {
120 require.Equal(t, "agent done", gotBody)120 require.Equal(t, "agent done", gotBody)
121 }121 }
122 122
123+func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
124+ s := NewScreen(10, 2)
125+ calls := 0
126+ s.OnNotify = func(string, string) { calls++ }
127+ s.Write([]byte("\x1b]9;4;1;50\x07"))
128+ require.Equal(t, 0, calls)
129+}
130+
123 func TestOnNotifyOSC777STTerminated(t *testing.T) {131 func TestOnNotifyOSC777STTerminated(t *testing.T) {
124 s := NewScreen(10, 2)132 s := NewScreen(10, 2)
125 var gotTitle, gotBody string133 var gotTitle, gotBody string

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