detached cf5ca03

Merge pull request #1 from bnema/refactor/scrollback-visual-mode

refactor(copy): present passive scrollback mode
commit
cf5ca0307f1167f879868b261021f2c504eb86f5
author
bnema · · 2026-07-03 05:16 UTC
committer
GitHub · · 2026-07-03 05:16 UTC
merge of
631d133 + 50f59e4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +165 -35
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. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead.
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. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead.
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 +52 -15
@@ -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
@@ -225,18 +226,28 @@ func (ac *attachedClient) send(f ports.Frame) error {
225226 // write. Detach/kill/shutdown paths use this so they are never gated on a
226227 // client that has stopped draining its socket.
227228 func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
229+ _ = d.boundedSendErr(ac, f)
230+}
231+
232+func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
228233 timer := d.clock.NewTimer(detachNotifyTimeout)
229- sent := make(chan struct{})
234+ result := make(chan error, 1)
230235 go func() {
236+ result <- ac.send(f)
237+ }()
238+ select {
239+ case err := <-result:
240+ timer.Stop()
241+ return err
242+ case <-timer.C():
231243 select {
232- case <-timer.C():
233- _ = ac.tr.Close()
234- case <-sent:
244+ case err := <-result:
245+ return err
246+ default:
235247 }
236- }()
237- _ = ac.send(f)
238- timer.Stop()
239- close(sent)
248+ _ = ac.tr.Close()
249+ return errors.New("send timed out")
250+ }
240251 }
241252
242253 // notifyDetachedAsync delivers a best-effort Detached notice off the caller's
@@ -946,12 +957,20 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
946957 tb.mu.Unlock()
947958
948959 if copyOut && text != "" {
949- for _, chunk := range scopy.OSC52(text) {
950- if err := ac.send(frameOutput(chunk)); err != nil {
960+ chunks := scopy.OSC52(text)
961+ for _, chunk := range chunks {
962+ if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
951963 d.detachOnSendError(sess, ac)
952964 return
953965 }
954966 }
967+ ac.copyMu.Lock()
968+ if len(chunks) > 0 {
969+ ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
970+ } else {
971+ ac.copyFeedback = "selection too large to copy"
972+ }
973+ ac.copyMu.Unlock()
955974 }
956975 if exit {
957976 d.paint(sess, ac, true)
@@ -1619,7 +1638,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
16191638 ac.sendMu.Lock()
16201639 ac.copyMu.Lock()
16211640 copyActive := ac.copyMode != nil
1622- copyMode := ac.copyMode
1641+ var copyMode *scopy.Mode
1642+ if ac.copyMode != nil {
1643+ copyModeValue := *ac.copyMode
1644+ copyMode = &copyModeValue
1645+ }
1646+ copyFeedback := ac.copyFeedback
1647+ if copyFeedback != "" && !copyActive {
1648+ ac.copyFeedback = ""
1649+ }
16231650 ac.copyMu.Unlock()
16241651 ac.pickerMu.Lock()
16251652 pickerActive := ac.picker != nil
@@ -1634,7 +1661,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
16341661 if reset || pickerActive {
16351662 ac.lastCursor.valid = false
16361663 }
1637- frame, damage := composeClientFrame(sess, tb, reset)
1664+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
16381665 if copyActive {
16391666 frame, damage = composeCopyClientFrame(copyMode, tb)
16401667 }
@@ -1712,13 +1739,13 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
17121739 return b
17131740 }
17141741
1715-func composeClientFrame(sess *session, tb *tab, full bool) (renderer.Frame, []renderer.Damage) {
1742+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
17161743 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
17171744 frame := renderer.NewFrame(width, screenRows+1)
17181745 for y := range screenRows {
17191746 copy(frame.Row(y), tb.screen.Frame.Row(y))
17201747 }
1721- drawStatus(frame.Row(screenRows), sess)
1748+ drawStatus(frame.Row(screenRows), sess, rightStatus)
17221749 if full {
17231750 return frame, []renderer.Damage{renderer.FullRedraw()}
17241751 }
@@ -1761,7 +1788,7 @@ func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
17611788 return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
17621789 }
17631790
1764-func drawStatus(row []renderer.Cell, sess *session) {
1791+func drawStatus(row []renderer.Cell, sess *session, rightText string) {
17651792 for i := range row {
17661793 row[i] = renderer.BlankCell()
17671794 }
@@ -1775,6 +1802,16 @@ func drawStatus(row []renderer.Cell, sess *session) {
17751802 }
17761803 writeStatusText(row, &x, " "+w.name+" ", style)
17771804 }
1805+ if rightText == "" {
1806+ return
1807+ }
1808+ style := renderer.DefaultStyle()
1809+ rightWidth := len([]rune(rightText)) + 1
1810+ if len(row)-x-1 < rightWidth {
1811+ return
1812+ }
1813+ x = len(row) - rightWidth
1814+ writeStatusText(row, &x, " "+rightText, style)
17781815 }
17791816
17801817 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
@@ -225,18 +226,28 @@ func (ac *attachedClient) send(f ports.Frame) error {
225 // write. Detach/kill/shutdown paths use this so they are never gated on a226 // write. Detach/kill/shutdown paths use this so they are never gated on a
226 // client that has stopped draining its socket.227 // client that has stopped draining its socket.
227 func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {228 func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
229+ _ = d.boundedSendErr(ac, f)
230+}
231+
232+func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
228 timer := d.clock.NewTimer(detachNotifyTimeout)233 timer := d.clock.NewTimer(detachNotifyTimeout)
229- sent := make(chan struct{})234+ result := make(chan error, 1)
230 go func() {235 go func() {
236+ result <- ac.send(f)
237+ }()
238+ select {
239+ case err := <-result:
240+ timer.Stop()
241+ return err
242+ case <-timer.C():
231 select {243 select {
232- case <-timer.C():244+ case err := <-result:
233- _ = ac.tr.Close()245+ return err
234- case <-sent:246+ default:
235 }247 }
236- }()248+ _ = ac.tr.Close()
237- _ = ac.send(f)249+ return errors.New("send timed out")
238- timer.Stop()250+ }
239- close(sent)
240 }251 }
241 252
242 // notifyDetachedAsync delivers a best-effort Detached notice off the caller's253 // notifyDetachedAsync delivers a best-effort Detached notice off the caller's
@@ -946,12 +957,20 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
946 tb.mu.Unlock()957 tb.mu.Unlock()
947 958
948 if copyOut && text != "" {959 if copyOut && text != "" {
949- for _, chunk := range scopy.OSC52(text) {960+ chunks := scopy.OSC52(text)
950- if err := ac.send(frameOutput(chunk)); err != nil {961+ for _, chunk := range chunks {
962+ if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
951 d.detachOnSendError(sess, ac)963 d.detachOnSendError(sess, ac)
952 return964 return
953 }965 }
954 }966 }
967+ ac.copyMu.Lock()
968+ if len(chunks) > 0 {
969+ ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
970+ } else {
971+ ac.copyFeedback = "selection too large to copy"
972+ }
973+ ac.copyMu.Unlock()
955 }974 }
956 if exit {975 if exit {
957 d.paint(sess, ac, true)976 d.paint(sess, ac, true)
@@ -1619,7 +1638,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1619 ac.sendMu.Lock()1638 ac.sendMu.Lock()
1620 ac.copyMu.Lock()1639 ac.copyMu.Lock()
1621 copyActive := ac.copyMode != nil1640 copyActive := ac.copyMode != nil
1622- copyMode := ac.copyMode1641+ var copyMode *scopy.Mode
1642+ if ac.copyMode != nil {
1643+ copyModeValue := *ac.copyMode
1644+ copyMode = &copyModeValue
1645+ }
1646+ copyFeedback := ac.copyFeedback
1647+ if copyFeedback != "" && !copyActive {
1648+ ac.copyFeedback = ""
1649+ }
1623 ac.copyMu.Unlock()1650 ac.copyMu.Unlock()
1624 ac.pickerMu.Lock()1651 ac.pickerMu.Lock()
1625 pickerActive := ac.picker != nil1652 pickerActive := ac.picker != nil
@@ -1634,7 +1661,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1634 if reset || pickerActive {1661 if reset || pickerActive {
1635 ac.lastCursor.valid = false1662 ac.lastCursor.valid = false
1636 }1663 }
1637- frame, damage := composeClientFrame(sess, tb, reset)1664+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
1638 if copyActive {1665 if copyActive {
1639 frame, damage = composeCopyClientFrame(copyMode, tb)1666 frame, damage = composeCopyClientFrame(copyMode, tb)
1640 }1667 }
@@ -1712,13 +1739,13 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
1712 return b1739 return b
1713 }1740 }
1714 1741
1715-func composeClientFrame(sess *session, tb *tab, full bool) (renderer.Frame, []renderer.Damage) {1742+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
1716 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height1743 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
1717 frame := renderer.NewFrame(width, screenRows+1)1744 frame := renderer.NewFrame(width, screenRows+1)
1718 for y := range screenRows {1745 for y := range screenRows {
1719 copy(frame.Row(y), tb.screen.Frame.Row(y))1746 copy(frame.Row(y), tb.screen.Frame.Row(y))
1720 }1747 }
1721- drawStatus(frame.Row(screenRows), sess)1748+ drawStatus(frame.Row(screenRows), sess, rightStatus)
1722 if full {1749 if full {
1723 return frame, []renderer.Damage{renderer.FullRedraw()}1750 return frame, []renderer.Damage{renderer.FullRedraw()}
1724 }1751 }
@@ -1761,7 +1788,7 @@ func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
1761 return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}1788 return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
1762 }1789 }
1763 1790
1764-func drawStatus(row []renderer.Cell, sess *session) {1791+func drawStatus(row []renderer.Cell, sess *session, rightText string) {
1765 for i := range row {1792 for i := range row {
1766 row[i] = renderer.BlankCell()1793 row[i] = renderer.BlankCell()
1767 }1794 }
@@ -1775,6 +1802,16 @@ func drawStatus(row []renderer.Cell, sess *session) {
1775 }1802 }
1776 writeStatusText(row, &x, " "+w.name+" ", style)1803 writeStatusText(row, &x, " "+w.name+" ", style)
1777 }1804 }
1805+ if rightText == "" {
1806+ return
1807+ }
1808+ style := renderer.DefaultStyle()
1809+ rightWidth := len([]rune(rightText)) + 1
1810+ if len(row)-x-1 < rightWidth {
1811+ return
1812+ }
1813+ x = len(row) - rightWidth
1814+ writeStatusText(row, &x, " "+rightText, style)
1778 }1815 }
1779 1816
1780 type statusSnapshot struct {1817 type statusSnapshot struct {
M internal/usecase/daemon/daemon_test.go +72 -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) {
@@ -494,6 +513,29 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
494513 }
495514 }
496515
516+func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
517+ p, _ := newBlockingPTY(t)
518+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
519+ sess.tabs[0].scrollback = scopy.NewScrollback(1)
520+ longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
521+ sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
522+ copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
523+
524+ d.enterCopyMode(sess, ac)
525+ awaitFrame(t, sends, ports.MsgOutput)
526+ d.handleInput(sess, ac, []byte{' ', 'y'})
527+
528+ out := awaitFrame(t, sends, ports.MsgOutput)
529+ msg, err := ports.UnmarshalOutput(out.Payload)
530+ require.NoError(t, err)
531+ if strings.Contains(string(msg.Data), "\x1b]52;") {
532+ t.Fatalf("oversized yank emitted OSC52: %q", string(msg.Data))
533+ }
534+ if !strings.Contains(string(msg.Data), "selection too large to copy") {
535+ t.Fatalf("oversized yank repaint = %q, want too-large feedback", string(msg.Data))
536+ }
537+}
538+
497539 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
498540 cases := []struct {
499541 name string
@@ -1185,7 +1227,7 @@ func TestStatusCompositionGolden(t *testing.T) {
11851227 win.size = domain.Size{Cols: 12, Rows: 2}
11861228 win.screen.Write([]byte("hello"))
11871229
1188- frame, damage := composeClientFrame(sess, win, true)
1230+ frame, damage := composeClientFrame(sess, win, true, "")
11891231
11901232 require.Equal(t, 12, frame.Width)
11911233 require.Equal(t, 3, frame.Height)
@@ -1199,6 +1241,25 @@ func TestStatusCompositionGolden(t *testing.T) {
11991241 }
12001242 }
12011243
1244+func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
1245+ p, releasePTY := newBlockingPTY(t)
1246+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
1247+ defer releasePTY()
1248+ sess.name = "work"
1249+ win := sess.activeTab()
1250+ win.screen = vt.NewScreen(30, 2)
1251+ win.size = domain.Size{Cols: 30, Rows: 2}
1252+
1253+ frame, _ := composeClientFrame(sess, win, true, "ok")
1254+ require.Equal(t, " work 1 ok", rowText(frame.Row(2)))
1255+
1256+ frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
1257+ require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))
1258+
1259+ frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
1260+ require.Equal(t, " work 1 ", rowText(frame.Row(2)))
1261+}
1262+
12021263 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
12031264 p1, releasePTY1 := newBlockingPTY(t)
12041265 p2, releasePTY2 := newBlockingPTY(t)
@@ -2013,7 +2074,7 @@ func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
20132074 return out.Data
20142075 }
20152076
2016-func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
2077+func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
20172078 writes := make(chan []byte, 4)
20182079 p, _ := newBlockingPTYWithWrites(t, writes)
20192080 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -2021,9 +2082,10 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
20212082 win.screen.Write([]byte("live"))
20222083
20232084 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
2024- mustOutputData(t, sends)
2085+ data := mustOutputData(t, sends)
20252086 require.NotNil(t, ac.copyMode)
20262087 require.Equal(t, 19, ac.copyMode.Cursor)
2088+ require.Contains(t, string(data), "[SCROLL]")
20272089
20282090 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
20292091 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) {
@@ -494,6 +513,29 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
494 }513 }
495 }514 }
496 515
516+func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
517+ p, _ := newBlockingPTY(t)
518+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
519+ sess.tabs[0].scrollback = scopy.NewScrollback(1)
520+ longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
521+ sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
522+ copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
523+
524+ d.enterCopyMode(sess, ac)
525+ awaitFrame(t, sends, ports.MsgOutput)
526+ d.handleInput(sess, ac, []byte{' ', 'y'})
527+
528+ out := awaitFrame(t, sends, ports.MsgOutput)
529+ msg, err := ports.UnmarshalOutput(out.Payload)
530+ require.NoError(t, err)
531+ if strings.Contains(string(msg.Data), "\x1b]52;") {
532+ t.Fatalf("oversized yank emitted OSC52: %q", string(msg.Data))
533+ }
534+ if !strings.Contains(string(msg.Data), "selection too large to copy") {
535+ t.Fatalf("oversized yank repaint = %q, want too-large feedback", string(msg.Data))
536+ }
537+}
538+
497 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {539 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
498 cases := []struct {540 cases := []struct {
499 name string541 name string
@@ -1185,7 +1227,7 @@ func TestStatusCompositionGolden(t *testing.T) {
1185 win.size = domain.Size{Cols: 12, Rows: 2}1227 win.size = domain.Size{Cols: 12, Rows: 2}
1186 win.screen.Write([]byte("hello"))1228 win.screen.Write([]byte("hello"))
1187 1229
1188- frame, damage := composeClientFrame(sess, win, true)1230+ frame, damage := composeClientFrame(sess, win, true, "")
1189 1231
1190 require.Equal(t, 12, frame.Width)1232 require.Equal(t, 12, frame.Width)
1191 require.Equal(t, 3, frame.Height)1233 require.Equal(t, 3, frame.Height)
@@ -1199,6 +1241,25 @@ func TestStatusCompositionGolden(t *testing.T) {
1199 }1241 }
1200 }1242 }
1201 1243
1244+func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
1245+ p, releasePTY := newBlockingPTY(t)
1246+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
1247+ defer releasePTY()
1248+ sess.name = "work"
1249+ win := sess.activeTab()
1250+ win.screen = vt.NewScreen(30, 2)
1251+ win.size = domain.Size{Cols: 30, Rows: 2}
1252+
1253+ frame, _ := composeClientFrame(sess, win, true, "ok")
1254+ require.Equal(t, " work 1 ok", rowText(frame.Row(2)))
1255+
1256+ frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
1257+ require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))
1258+
1259+ frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
1260+ require.Equal(t, " work 1 ", rowText(frame.Row(2)))
1261+}
1262+
1202 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {1263 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
1203 p1, releasePTY1 := newBlockingPTY(t)1264 p1, releasePTY1 := newBlockingPTY(t)
1204 p2, releasePTY2 := newBlockingPTY(t)1265 p2, releasePTY2 := newBlockingPTY(t)
@@ -2013,7 +2074,7 @@ func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
2013 return out.Data2074 return out.Data
2014 }2075 }
2015 2076
2016-func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {2077+func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
2017 writes := make(chan []byte, 4)2078 writes := make(chan []byte, 4)
2018 p, _ := newBlockingPTYWithWrites(t, writes)2079 p, _ := newBlockingPTYWithWrites(t, writes)
2019 d, sess, ac, sends := newManualSessionWithPTYs(t, p)2080 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -2021,9 +2082,10 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
2021 win.screen.Write([]byte("live"))2082 win.screen.Write([]byte("live"))
2022 2083
2023 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))2084 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
2024- mustOutputData(t, sends)2085+ data := mustOutputData(t, sends)
2025 require.NotNil(t, ac.copyMode)2086 require.NotNil(t, ac.copyMode)
2026 require.Equal(t, 19, ac.copyMode.Cursor)2087 require.Equal(t, 19, ac.copyMode.Cursor)
2088+ require.Contains(t, string(data), "[SCROLL]")
2027 2089
2028 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))2090 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
2029 mustOutputData(t, sends)2091 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 12

Tags 11

↑↓ moveenter openesc close