detached 3b80c55

refactor(copy): present passive scrollback mode

commit
3b80c551600acb731a6b2691075b2b05540139f9
author
brice · · 2026-07-03 04:43 UTC
parent
631d133
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +102 -26
M README.md +6 -6
@@ -49,26 +49,26 @@ All bindings use the Alt modifier directly, no prefix key.
4949 | Alt+d | detach |
5050 | Alt+r | promote the current ephemeral session to named |
5151 | Alt+t | open session/tab picker |
52-| Alt+u | enter copy mode |
52+| Alt+u | enter scrollback mode |
5353
54-## Copy mode
54+## Scrollback and visual copy
5555
56-Copy mode freezes a view over the scrollback while the program keeps running underneath. The status bar shows `[COPY]` and the cursor position.
56+Scrollback mode freezes a view over history while the program keeps running underneath. Mouse wheel up enters scrollback mode and shows `[SCROLL]`; scrolling back down to the live bottom exits. When a line selection is active, the selected rows are highlighted and the status bar shows `[VISUAL]`.
5757
5858 | Key | Action |
5959 |---|---|
6060 | j / k, Up / Down | move one line |
6161 | PgUp / PgDn | move one page |
6262 | g / G | jump to top / bottom |
63-| Space | toggle line selection |
63+| Space | toggle visual line selection |
6464 | y or Enter | copy selection and exit |
6565 | q, Esc, Ctrl-C | exit without copying |
6666
67-Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH.
67+Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update.
6868
6969 ## Mouse
7070
71-vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In copy mode, the wheel scrolls the copy-mode view. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
71+vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In scrollback mode, the wheel scrolls the scrollback view. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
7272
7373 vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.
7474
@@ -49,26 +49,26 @@ All bindings use the Alt modifier directly, no prefix key.
49 | Alt+d | detach |49 | Alt+d | detach |
50 | Alt+r | promote the current ephemeral session to named |50 | Alt+r | promote the current ephemeral session to named |
51 | Alt+t | open session/tab picker |51 | Alt+t | open session/tab picker |
52-| Alt+u | enter copy mode |52+| Alt+u | enter scrollback mode |
53 53
54-## Copy mode54+## Scrollback and visual copy
55 55
56-Copy mode freezes a view over the scrollback while the program keeps running underneath. The status bar shows `[COPY]` and the cursor position.56+Scrollback mode freezes a view over history while the program keeps running underneath. Mouse wheel up enters scrollback mode and shows `[SCROLL]`; scrolling back down to the live bottom exits. When a line selection is active, the selected rows are highlighted and the status bar shows `[VISUAL]`.
57 57
58 | Key | Action |58 | Key | Action |
59 |---|---|59 |---|---|
60 | j / k, Up / Down | move one line |60 | j / k, Up / Down | move one line |
61 | PgUp / PgDn | move one page |61 | PgUp / PgDn | move one page |
62 | g / G | jump to top / bottom |62 | g / G | jump to top / bottom |
63-| Space | toggle line selection |63+| Space | toggle visual line selection |
64 | y or Enter | copy selection and exit |64 | y or Enter | copy selection and exit |
65 | q, Esc, Ctrl-C | exit without copying |65 | q, Esc, Ctrl-C | exit without copying |
66 66
67-Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH.67+Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update.
68 68
69 ## Mouse69 ## Mouse
70 70
71-vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In copy mode, the wheel scrolls the copy-mode view. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.71+vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In scrollback mode, the wheel scrolls the scrollback view. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
72 72
73 vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.73 vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.
74 74
M internal/usecase/copy/mode.go +6 -3
@@ -14,7 +14,7 @@ import (
1414 // specific continuation protocol is supported.
1515 const OSC52MaxPayloadBytes = 75_000
1616
17-// Snapshot is the copy-mode document: scrollback followed by the live screen.
17+// Snapshot is the scrollback-mode document: scrollback followed by the live screen.
1818 type Snapshot struct {
1919 Rows [][]renderer.Cell
2020 Width int
@@ -39,7 +39,7 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
3939 return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}
4040 }
4141
42-// Mode stores per-client copy-mode viewport and line-selection state.
42+// Mode stores per-client scrollback viewport and line-selection state.
4343 type Mode struct {
4444 ViewportTop int
4545 Cursor int
@@ -166,7 +166,10 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
166166 for i := range row {
167167 row[i] = renderer.BlankCell()
168168 }
169- text := " [COPY] "
169+ text := " [SCROLL] "
170+ if m.Selecting {
171+ text = " [VISUAL] "
172+ }
170173 if total > 0 {
171174 text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " "
172175 } else {
@@ -14,7 +14,7 @@ import (
14 // specific continuation protocol is supported.14 // specific continuation protocol is supported.
15 const OSC52MaxPayloadBytes = 75_00015 const OSC52MaxPayloadBytes = 75_000
16 16
17-// Snapshot is the copy-mode document: scrollback followed by the live screen.17+// Snapshot is the scrollback-mode document: scrollback followed by the live screen.
18 type Snapshot struct {18 type Snapshot struct {
19 Rows [][]renderer.Cell19 Rows [][]renderer.Cell
20 Width int20 Width int
@@ -39,7 +39,7 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
39 return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}39 return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}
40 }40 }
41 41
42-// Mode stores per-client copy-mode viewport and line-selection state.42+// Mode stores per-client scrollback viewport and line-selection state.
43 type Mode struct {43 type Mode struct {
44 ViewportTop int44 ViewportTop int
45 Cursor int45 Cursor int
@@ -166,7 +166,10 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
166 for i := range row {166 for i := range row {
167 row[i] = renderer.BlankCell()167 row[i] = renderer.BlankCell()
168 }168 }
169- text := " [COPY] "169+ text := " [SCROLL] "
170+ if m.Selecting {
171+ text = " [VISUAL] "
172+ }
170 if total > 0 {173 if total > 0 {
171 text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " "174 text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " "
172 } else {175 } else {
M internal/usecase/copy/mode_test.go +29 -1
@@ -13,7 +13,7 @@ func snapshot(lines []string, height int) Snapshot {
1313 for i, line := range lines {
1414 rows[i] = row(line)
1515 }
16- return Snapshot{Rows: rows, Width: 8, Height: height}
16+ return Snapshot{Rows: rows, Width: 16, Height: height}
1717 }
1818
1919 func TestCopyModeNavigationBounds(t *testing.T) {
@@ -129,6 +129,22 @@ func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) {
129129 }
130130 }
131131
132+func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
133+ s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
134+ m := NewMode(s)
135+
136+ frame := m.Render(s)
137+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") || strings.Contains(got, "[VISUAL]") {
138+ t.Fatalf("passive status = %q, want [SCROLL] and no copy/visual label", got)
139+ }
140+
141+ m.ToggleSelection()
142+ frame = m.Render(s)
143+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
144+ t.Fatalf("selection status = %q, want [VISUAL] and no [SCROLL]", got)
145+ }
146+}
147+
132148 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
133149 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
134150 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
@@ -145,6 +161,18 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
145161 }
146162 }
147163
164+func frameText(row []renderer.Cell) string {
165+ var b strings.Builder
166+ for _, c := range row {
167+ if c.Rune == 0 {
168+ b.WriteRune(' ')
169+ continue
170+ }
171+ b.WriteRune(c.Rune)
172+ }
173+ return b.String()
174+}
175+
148176 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {
149177 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)
150178 m := NewMode(s)
@@ -13,7 +13,7 @@ func snapshot(lines []string, height int) Snapshot {
13 for i, line := range lines {13 for i, line := range lines {
14 rows[i] = row(line)14 rows[i] = row(line)
15 }15 }
16- return Snapshot{Rows: rows, Width: 8, Height: height}16+ return Snapshot{Rows: rows, Width: 16, Height: height}
17 }17 }
18 18
19 func TestCopyModeNavigationBounds(t *testing.T) {19 func TestCopyModeNavigationBounds(t *testing.T) {
@@ -129,6 +129,22 @@ func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) {
129 }129 }
130 }130 }
131 131
132+func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
133+ s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
134+ m := NewMode(s)
135+
136+ frame := m.Render(s)
137+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") || strings.Contains(got, "[VISUAL]") {
138+ t.Fatalf("passive status = %q, want [SCROLL] and no copy/visual label", got)
139+ }
140+
141+ m.ToggleSelection()
142+ frame = m.Render(s)
143+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
144+ t.Fatalf("selection status = %q, want [VISUAL] and no [SCROLL]", got)
145+ }
146+}
147+
132 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {148 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
133 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)149 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
134 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}150 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
@@ -145,6 +161,18 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
145 }161 }
146 }162 }
147 163
164+func frameText(row []renderer.Cell) string {
165+ var b strings.Builder
166+ for _, c := range row {
167+ if c.Rune == 0 {
168+ b.WriteRune(' ')
169+ continue
170+ }
171+ b.WriteRune(c.Rune)
172+ }
173+ return b.String()
174+}
175+
148 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {176 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {
149 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)177 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)
150 m := NewMode(s)178 m := NewMode(s)
M internal/usecase/daemon/daemon.go +31 -6
@@ -170,6 +170,7 @@ type attachedClient struct {
170170 copyMu sync.Mutex
171171 copyMode *scopy.Mode
172172 copyPending []byte
173+ copyFeedback string
173174 pickerMu sync.Mutex
174175 picker *picker.Model
175176 pickerPreview *tab
@@ -946,12 +947,18 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
946947 tb.mu.Unlock()
947948
948949 if copyOut && text != "" {
949- for _, chunk := range scopy.OSC52(text) {
950+ chunks := scopy.OSC52(text)
951+ for _, chunk := range chunks {
950952 if err := ac.send(frameOutput(chunk)); err != nil {
951953 d.detachOnSendError(sess, ac)
952954 return
953955 }
954956 }
957+ if len(chunks) > 0 {
958+ ac.copyMu.Lock()
959+ ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
960+ ac.copyMu.Unlock()
961+ }
955962 }
956963 if exit {
957964 d.paint(sess, ac, true)
@@ -1619,7 +1626,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
16191626 ac.sendMu.Lock()
16201627 ac.copyMu.Lock()
16211628 copyActive := ac.copyMode != nil
1622- copyMode := ac.copyMode
1629+ var copyMode *scopy.Mode
1630+ if ac.copyMode != nil {
1631+ copyModeValue := *ac.copyMode
1632+ copyMode = &copyModeValue
1633+ }
1634+ copyFeedback := ac.copyFeedback
1635+ if copyFeedback != "" && !copyActive {
1636+ ac.copyFeedback = ""
1637+ }
16231638 ac.copyMu.Unlock()
16241639 ac.pickerMu.Lock()
16251640 pickerActive := ac.picker != nil
@@ -1634,7 +1649,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
16341649 if reset || pickerActive {
16351650 ac.lastCursor.valid = false
16361651 }
1637- frame, damage := composeClientFrame(sess, tb, reset)
1652+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
16381653 if copyActive {
16391654 frame, damage = composeCopyClientFrame(copyMode, tb)
16401655 }
@@ -1712,13 +1727,13 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
17121727 return b
17131728 }
17141729
1715-func composeClientFrame(sess *session, tb *tab, full bool) (renderer.Frame, []renderer.Damage) {
1730+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
17161731 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
17171732 frame := renderer.NewFrame(width, screenRows+1)
17181733 for y := range screenRows {
17191734 copy(frame.Row(y), tb.screen.Frame.Row(y))
17201735 }
1721- drawStatus(frame.Row(screenRows), sess)
1736+ drawStatus(frame.Row(screenRows), sess, rightStatus)
17221737 if full {
17231738 return frame, []renderer.Damage{renderer.FullRedraw()}
17241739 }
@@ -1761,7 +1776,7 @@ func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
17611776 return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
17621777 }
17631778
1764-func drawStatus(row []renderer.Cell, sess *session) {
1779+func drawStatus(row []renderer.Cell, sess *session, rightText string) {
17651780 for i := range row {
17661781 row[i] = renderer.BlankCell()
17671782 }
@@ -1775,6 +1790,16 @@ func drawStatus(row []renderer.Cell, sess *session) {
17751790 }
17761791 writeStatusText(row, &x, " "+w.name+" ", style)
17771792 }
1793+ if rightText == "" {
1794+ return
1795+ }
1796+ style := renderer.DefaultStyle()
1797+ start := max(len(row)-len([]rune(rightText))-1, x+1)
1798+ if start >= len(row) {
1799+ return
1800+ }
1801+ x = start
1802+ writeStatusText(row, &x, " "+rightText, style)
17781803 }
17791804
17801805 type statusSnapshot struct {
@@ -170,6 +170,7 @@ type attachedClient struct {
170 copyMu sync.Mutex170 copyMu sync.Mutex
171 copyMode *scopy.Mode171 copyMode *scopy.Mode
172 copyPending []byte172 copyPending []byte
173+ copyFeedback string
173 pickerMu sync.Mutex174 pickerMu sync.Mutex
174 picker *picker.Model175 picker *picker.Model
175 pickerPreview *tab176 pickerPreview *tab
@@ -946,12 +947,18 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
946 tb.mu.Unlock()947 tb.mu.Unlock()
947 948
948 if copyOut && text != "" {949 if copyOut && text != "" {
949- for _, chunk := range scopy.OSC52(text) {950+ chunks := scopy.OSC52(text)
951+ for _, chunk := range chunks {
950 if err := ac.send(frameOutput(chunk)); err != nil {952 if err := ac.send(frameOutput(chunk)); err != nil {
951 d.detachOnSendError(sess, ac)953 d.detachOnSendError(sess, ac)
952 return954 return
953 }955 }
954 }956 }
957+ if len(chunks) > 0 {
958+ ac.copyMu.Lock()
959+ ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
960+ ac.copyMu.Unlock()
961+ }
955 }962 }
956 if exit {963 if exit {
957 d.paint(sess, ac, true)964 d.paint(sess, ac, true)
@@ -1619,7 +1626,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1619 ac.sendMu.Lock()1626 ac.sendMu.Lock()
1620 ac.copyMu.Lock()1627 ac.copyMu.Lock()
1621 copyActive := ac.copyMode != nil1628 copyActive := ac.copyMode != nil
1622- copyMode := ac.copyMode1629+ var copyMode *scopy.Mode
1630+ if ac.copyMode != nil {
1631+ copyModeValue := *ac.copyMode
1632+ copyMode = &copyModeValue
1633+ }
1634+ copyFeedback := ac.copyFeedback
1635+ if copyFeedback != "" && !copyActive {
1636+ ac.copyFeedback = ""
1637+ }
1623 ac.copyMu.Unlock()1638 ac.copyMu.Unlock()
1624 ac.pickerMu.Lock()1639 ac.pickerMu.Lock()
1625 pickerActive := ac.picker != nil1640 pickerActive := ac.picker != nil
@@ -1634,7 +1649,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1634 if reset || pickerActive {1649 if reset || pickerActive {
1635 ac.lastCursor.valid = false1650 ac.lastCursor.valid = false
1636 }1651 }
1637- frame, damage := composeClientFrame(sess, tb, reset)1652+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
1638 if copyActive {1653 if copyActive {
1639 frame, damage = composeCopyClientFrame(copyMode, tb)1654 frame, damage = composeCopyClientFrame(copyMode, tb)
1640 }1655 }
@@ -1712,13 +1727,13 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
1712 return b1727 return b
1713 }1728 }
1714 1729
1715-func composeClientFrame(sess *session, tb *tab, full bool) (renderer.Frame, []renderer.Damage) {1730+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
1716 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height1731 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
1717 frame := renderer.NewFrame(width, screenRows+1)1732 frame := renderer.NewFrame(width, screenRows+1)
1718 for y := range screenRows {1733 for y := range screenRows {
1719 copy(frame.Row(y), tb.screen.Frame.Row(y))1734 copy(frame.Row(y), tb.screen.Frame.Row(y))
1720 }1735 }
1721- drawStatus(frame.Row(screenRows), sess)1736+ drawStatus(frame.Row(screenRows), sess, rightStatus)
1722 if full {1737 if full {
1723 return frame, []renderer.Damage{renderer.FullRedraw()}1738 return frame, []renderer.Damage{renderer.FullRedraw()}
1724 }1739 }
@@ -1761,7 +1776,7 @@ func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
1761 return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}1776 return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
1762 }1777 }
1763 1778
1764-func drawStatus(row []renderer.Cell, sess *session) {1779+func drawStatus(row []renderer.Cell, sess *session, rightText string) {
1765 for i := range row {1780 for i := range row {
1766 row[i] = renderer.BlankCell()1781 row[i] = renderer.BlankCell()
1767 }1782 }
@@ -1775,6 +1790,16 @@ func drawStatus(row []renderer.Cell, sess *session) {
1775 }1790 }
1776 writeStatusText(row, &x, " "+w.name+" ", style)1791 writeStatusText(row, &x, " "+w.name+" ", style)
1777 }1792 }
1793+ if rightText == "" {
1794+ return
1795+ }
1796+ style := renderer.DefaultStyle()
1797+ start := max(len(row)-len([]rune(rightText))-1, x+1)
1798+ if start >= len(row) {
1799+ return
1800+ }
1801+ x = start
1802+ writeStatusText(row, &x, " "+rightText, style)
1778 }1803 }
1779 1804
1780 type statusSnapshot struct {1805 type statusSnapshot struct {
M internal/usecase/daemon/daemon_test.go +30 -10
@@ -306,7 +306,7 @@ func testRow(text string) []renderer.Cell {
306306 return cells
307307 }
308308
309-func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) {
309+func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) {
310310 writes := make(chan []byte, 1)
311311 p, _ := newBlockingPTYWithWrites(t, writes)
312312 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -316,18 +316,26 @@ func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) {
316316 d.handleInput(sess, ac, []byte("\x1bu"))
317317
318318 if ac.copyMode == nil {
319- t.Fatal("copy mode not entered")
319+ t.Fatal("scrollback mode not entered")
320320 }
321321 select {
322322 case got := <-writes:
323- t.Fatalf("copy-mode binding forwarded to PTY: %q", got)
323+ t.Fatalf("scrollback binding forwarded to PTY: %q", got)
324324 default:
325325 }
326326 out := awaitFrame(t, sends, ports.MsgOutput)
327327 msg, err := ports.UnmarshalOutput(out.Payload)
328328 require.NoError(t, err)
329- if !strings.Contains(string(msg.Data), "[COPY]") {
330- t.Fatalf("copy mode paint = %q, want [COPY] status", string(msg.Data))
329+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") {
330+ t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got)
331+ }
332+
333+ d.handleInput(sess, ac, []byte(" "))
334+ out = awaitFrame(t, sends, ports.MsgOutput)
335+ msg, err = ports.UnmarshalOutput(out.Payload)
336+ require.NoError(t, err)
337+ if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
338+ t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got)
331339 }
332340 }
333341
@@ -361,12 +369,23 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
361369 live := awaitFrame(t, sends, ports.MsgOutput)
362370 liveMsg, err := ports.UnmarshalOutput(live.Payload)
363371 require.NoError(t, err)
364- if strings.Contains(string(liveMsg.Data), "[COPY]") {
365- t.Fatalf("live repaint still contains copy status: %q", string(liveMsg.Data))
372+ if strings.Contains(string(liveMsg.Data), "[COPY]") || strings.Contains(string(liveMsg.Data), "[SCROLL]") {
373+ t.Fatalf("live repaint still contains copy/scroll status: %q", string(liveMsg.Data))
374+ }
375+ if !strings.Contains(string(liveMsg.Data), "copied 9 chars to clipboard") {
376+ t.Fatalf("live repaint = %q, want copy success feedback", string(liveMsg.Data))
366377 }
367378 if !strings.Contains(string(liveMsg.Data), "live") {
368379 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
369380 }
381+
382+ d.paint(sess, ac, true)
383+ followup := awaitFrame(t, sends, ports.MsgOutput)
384+ followupMsg, err := ports.UnmarshalOutput(followup.Payload)
385+ require.NoError(t, err)
386+ if strings.Contains(string(followupMsg.Data), "copied 9 chars to clipboard") {
387+ t.Fatalf("copy feedback persisted after next repaint: %q", string(followupMsg.Data))
388+ }
370389 }
371390
372391 func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
@@ -1185,7 +1204,7 @@ func TestStatusCompositionGolden(t *testing.T) {
11851204 win.size = domain.Size{Cols: 12, Rows: 2}
11861205 win.screen.Write([]byte("hello"))
11871206
1188- frame, damage := composeClientFrame(sess, win, true)
1207+ frame, damage := composeClientFrame(sess, win, true, "")
11891208
11901209 require.Equal(t, 12, frame.Width)
11911210 require.Equal(t, 3, frame.Height)
@@ -2013,7 +2032,7 @@ func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
20132032 return out.Data
20142033 }
20152034
2016-func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
2035+func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
20172036 writes := make(chan []byte, 4)
20182037 p, _ := newBlockingPTYWithWrites(t, writes)
20192038 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -2021,9 +2040,10 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
20212040 win.screen.Write([]byte("live"))
20222041
20232042 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
2024- mustOutputData(t, sends)
2043+ data := mustOutputData(t, sends)
20252044 require.NotNil(t, ac.copyMode)
20262045 require.Equal(t, 19, ac.copyMode.Cursor)
2046+ require.Contains(t, string(data), "[SCROLL]")
20272047
20282048 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
20292049 mustOutputData(t, sends)
@@ -306,7 +306,7 @@ func testRow(text string) []renderer.Cell {
306 return cells306 return cells
307 }307 }
308 308
309-func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) {309+func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) {
310 writes := make(chan []byte, 1)310 writes := make(chan []byte, 1)
311 p, _ := newBlockingPTYWithWrites(t, writes)311 p, _ := newBlockingPTYWithWrites(t, writes)
312 d, sess, ac, sends := newManualSessionWithPTYs(t, p)312 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -316,18 +316,26 @@ func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) {
316 d.handleInput(sess, ac, []byte("\x1bu"))316 d.handleInput(sess, ac, []byte("\x1bu"))
317 317
318 if ac.copyMode == nil {318 if ac.copyMode == nil {
319- t.Fatal("copy mode not entered")319+ t.Fatal("scrollback mode not entered")
320 }320 }
321 select {321 select {
322 case got := <-writes:322 case got := <-writes:
323- t.Fatalf("copy-mode binding forwarded to PTY: %q", got)323+ t.Fatalf("scrollback binding forwarded to PTY: %q", got)
324 default:324 default:
325 }325 }
326 out := awaitFrame(t, sends, ports.MsgOutput)326 out := awaitFrame(t, sends, ports.MsgOutput)
327 msg, err := ports.UnmarshalOutput(out.Payload)327 msg, err := ports.UnmarshalOutput(out.Payload)
328 require.NoError(t, err)328 require.NoError(t, err)
329- if !strings.Contains(string(msg.Data), "[COPY]") {329+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") {
330- t.Fatalf("copy mode paint = %q, want [COPY] status", string(msg.Data))330+ t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got)
331+ }
332+
333+ d.handleInput(sess, ac, []byte(" "))
334+ out = awaitFrame(t, sends, ports.MsgOutput)
335+ msg, err = ports.UnmarshalOutput(out.Payload)
336+ require.NoError(t, err)
337+ if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") {
338+ t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got)
331 }339 }
332 }340 }
333 341
@@ -361,12 +369,23 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
361 live := awaitFrame(t, sends, ports.MsgOutput)369 live := awaitFrame(t, sends, ports.MsgOutput)
362 liveMsg, err := ports.UnmarshalOutput(live.Payload)370 liveMsg, err := ports.UnmarshalOutput(live.Payload)
363 require.NoError(t, err)371 require.NoError(t, err)
364- if strings.Contains(string(liveMsg.Data), "[COPY]") {372+ if strings.Contains(string(liveMsg.Data), "[COPY]") || strings.Contains(string(liveMsg.Data), "[SCROLL]") {
365- t.Fatalf("live repaint still contains copy status: %q", string(liveMsg.Data))373+ t.Fatalf("live repaint still contains copy/scroll status: %q", string(liveMsg.Data))
374+ }
375+ if !strings.Contains(string(liveMsg.Data), "copied 9 chars to clipboard") {
376+ t.Fatalf("live repaint = %q, want copy success feedback", string(liveMsg.Data))
366 }377 }
367 if !strings.Contains(string(liveMsg.Data), "live") {378 if !strings.Contains(string(liveMsg.Data), "live") {
368 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))379 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
369 }380 }
381+
382+ d.paint(sess, ac, true)
383+ followup := awaitFrame(t, sends, ports.MsgOutput)
384+ followupMsg, err := ports.UnmarshalOutput(followup.Payload)
385+ require.NoError(t, err)
386+ if strings.Contains(string(followupMsg.Data), "copied 9 chars to clipboard") {
387+ t.Fatalf("copy feedback persisted after next repaint: %q", string(followupMsg.Data))
388+ }
370 }389 }
371 390
372 func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {391 func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
@@ -1185,7 +1204,7 @@ func TestStatusCompositionGolden(t *testing.T) {
1185 win.size = domain.Size{Cols: 12, Rows: 2}1204 win.size = domain.Size{Cols: 12, Rows: 2}
1186 win.screen.Write([]byte("hello"))1205 win.screen.Write([]byte("hello"))
1187 1206
1188- frame, damage := composeClientFrame(sess, win, true)1207+ frame, damage := composeClientFrame(sess, win, true, "")
1189 1208
1190 require.Equal(t, 12, frame.Width)1209 require.Equal(t, 12, frame.Width)
1191 require.Equal(t, 3, frame.Height)1210 require.Equal(t, 3, frame.Height)
@@ -2013,7 +2032,7 @@ func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
2013 return out.Data2032 return out.Data
2014 }2033 }
2015 2034
2016-func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {2035+func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
2017 writes := make(chan []byte, 4)2036 writes := make(chan []byte, 4)
2018 p, _ := newBlockingPTYWithWrites(t, writes)2037 p, _ := newBlockingPTYWithWrites(t, writes)
2019 d, sess, ac, sends := newManualSessionWithPTYs(t, p)2038 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -2021,9 +2040,10 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
2021 win.screen.Write([]byte("live"))2040 win.screen.Write([]byte("live"))
2022 2041
2023 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))2042 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
2024- mustOutputData(t, sends)2043+ data := mustOutputData(t, sends)
2025 require.NotNil(t, ac.copyMode)2044 require.NotNil(t, ac.copyMode)
2026 require.Equal(t, 19, ac.copyMode.Cursor)2045 require.Equal(t, 19, ac.copyMode.Cursor)
2046+ require.Contains(t, string(data), "[SCROLL]")
2027 2047
2028 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))2048 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
2029 mustOutputData(t, sends)2049 mustOutputData(t, sends)

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 2

Tags 13

↑↓ moveenter openesc close