refactor(copy): present passive scrollback mode
- commit
- 3b80c551600acb731a6b2691075b2b05540139f9
- author
- brice · · 2026-07-03 04:43 UTC
- parent
- 631d133
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |||
| 49 | 49 | | Alt+d | detach | | |
| 50 | 50 | | Alt+r | promote the current ephemeral session to named | | |
| 51 | 51 | | Alt+t | open session/tab picker | | |
| 52 | - | | Alt+u | enter copy mode | | |
| 52 | + | | Alt+u | enter scrollback mode | | |
| 53 | 53 | ||
| 54 | - | ## Copy mode | |
| 54 | + | ## 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 | 58 | | Key | Action | | |
| 59 | 59 | |---|---| | |
| 60 | 60 | | j / k, Up / Down | move one line | | |
| 61 | 61 | | PgUp / PgDn | move one page | | |
| 62 | 62 | | g / G | jump to top / bottom | | |
| 63 | - | | Space | toggle line selection | | |
| 63 | + | | Space | toggle visual line selection | | |
| 64 | 64 | | y or Enter | copy selection and exit | | |
| 65 | 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 | 69 | ## 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 | 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 | ||
| @@ -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 mode | 54 | + | ## 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 | ## Mouse | 69 | ## 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 ( | |||
| 14 | 14 | // specific continuation protocol is supported. | |
| 15 | 15 | 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 | 18 | type Snapshot struct { | |
| 19 | 19 | Rows [][]renderer.Cell | |
| 20 | 20 | Width int | |
| @@ -39,7 +39,7 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |||
| 39 | 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 | 43 | type Mode struct { | |
| 44 | 44 | ViewportTop int | |
| 45 | 45 | Cursor int | |
| @@ -166,7 +166,10 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | |||
| 166 | 166 | for i := range row { | |
| 167 | 167 | row[i] = renderer.BlankCell() | |
| 168 | 168 | } | |
| 169 | - | text := " [COPY] " | |
| 169 | + | text := " [SCROLL] " | |
| 170 | + | if m.Selecting { | |
| 171 | + | text = " [VISUAL] " | |
| 172 | + | } | |
| 170 | 173 | if total > 0 { | |
| 171 | 174 | text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " " | |
| 172 | 175 | } else { | |
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | // specific continuation protocol is supported. | 14 | // specific continuation protocol is supported. | ||
| 15 | const OSC52MaxPayloadBytes = 75_000 | 15 | 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.Cell | 19 | Rows [][]renderer.Cell | ||
| 20 | Width int | 20 | 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 int | 44 | ViewportTop int | ||
| 45 | Cursor int | 45 | 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 { | |||
| 13 | 13 | for i, line := range lines { | |
| 14 | 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 | 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 | 148 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |
| 133 | 149 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | |
| 134 | 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 | 176 | func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) { | |
| 149 | 177 | s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3) | |
| 150 | 178 | 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 { | |||
| 170 | 170 | copyMu sync.Mutex | |
| 171 | 171 | copyMode *scopy.Mode | |
| 172 | 172 | copyPending []byte | |
| 173 | + | copyFeedback string | |
| 173 | 174 | pickerMu sync.Mutex | |
| 174 | 175 | picker *picker.Model | |
| 175 | 176 | pickerPreview *tab | |
| @@ -946,12 +947,18 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 946 | 947 | tb.mu.Unlock() | |
| 947 | 948 | ||
| 948 | 949 | if copyOut && text != "" { | |
| 949 | - | for _, chunk := range scopy.OSC52(text) { | |
| 950 | + | chunks := scopy.OSC52(text) | |
| 951 | + | for _, chunk := range chunks { | |
| 950 | 952 | if err := ac.send(frameOutput(chunk)); err != nil { | |
| 951 | 953 | d.detachOnSendError(sess, ac) | |
| 952 | 954 | 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 | 963 | if exit { | |
| 957 | 964 | d.paint(sess, ac, true) | |
| @@ -1619,7 +1626,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 1619 | 1626 | ac.sendMu.Lock() | |
| 1620 | 1627 | ac.copyMu.Lock() | |
| 1621 | 1628 | copyActive := ac.copyMode != nil | |
| 1622 | - | copyMode := ac.copyMode | |
| 1629 | + | var copyMode *scopy.Mode | |
| 1630 | + | if ac.copyMode != nil { | |
| 1631 | + | copyModeValue := *ac.copyMode | |
| 1632 | + | copyMode = ©ModeValue | |
| 1633 | + | } | |
| 1634 | + | copyFeedback := ac.copyFeedback | |
| 1635 | + | if copyFeedback != "" && !copyActive { | |
| 1636 | + | ac.copyFeedback = "" | |
| 1637 | + | } | |
| 1623 | 1638 | ac.copyMu.Unlock() | |
| 1624 | 1639 | ac.pickerMu.Lock() | |
| 1625 | 1640 | pickerActive := ac.picker != nil | |
| @@ -1634,7 +1649,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 1634 | 1649 | if reset || pickerActive { | |
| 1635 | 1650 | ac.lastCursor.valid = false | |
| 1636 | 1651 | } | |
| 1637 | - | frame, damage := composeClientFrame(sess, tb, reset) | |
| 1652 | + | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback) | |
| 1638 | 1653 | if copyActive { | |
| 1639 | 1654 | frame, damage = composeCopyClientFrame(copyMode, tb) | |
| 1640 | 1655 | } | |
| @@ -1712,13 +1727,13 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||
| 1712 | 1727 | 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 | 1731 | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | |
| 1717 | 1732 | frame := renderer.NewFrame(width, screenRows+1) | |
| 1718 | 1733 | for y := range screenRows { | |
| 1719 | 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 | 1737 | if full { | |
| 1723 | 1738 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 1724 | 1739 | } | |
| @@ -1761,7 +1776,7 @@ func pickerPreviewFromLockedTab(tb *tab) picker.Preview { | |||
| 1761 | 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 | 1780 | for i := range row { | |
| 1766 | 1781 | row[i] = renderer.BlankCell() | |
| 1767 | 1782 | } | |
| @@ -1775,6 +1790,16 @@ func drawStatus(row []renderer.Cell, sess *session) { | |||
| 1775 | 1790 | } | |
| 1776 | 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 | 1805 | type statusSnapshot struct { | |
| @@ -170,6 +170,7 @@ type attachedClient struct { | |||||
| 170 | copyMu sync.Mutex | 170 | copyMu sync.Mutex | ||
| 171 | copyMode *scopy.Mode | 171 | copyMode *scopy.Mode | ||
| 172 | copyPending []byte | 172 | copyPending []byte | ||
| 173 | + | copyFeedback string | |||
| 173 | pickerMu sync.Mutex | 174 | pickerMu sync.Mutex | ||
| 174 | picker *picker.Model | 175 | picker *picker.Model | ||
| 175 | pickerPreview *tab | 176 | 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 | return | 954 | 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 != nil | 1628 | copyActive := ac.copyMode != nil | ||
| 1622 | - | copyMode := ac.copyMode | 1629 | + | var copyMode *scopy.Mode |
| 1630 | + | if ac.copyMode != nil { | |||
| 1631 | + | copyModeValue := *ac.copyMode | |||
| 1632 | + | copyMode = ©ModeValue | |||
| 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 != nil | 1640 | 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 = false | 1650 | 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 b | 1727 | 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.Height | 1731 | 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 { | |||
| 306 | 306 | return cells | |
| 307 | 307 | } | |
| 308 | 308 | ||
| 309 | - | func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) { | |
| 309 | + | func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) { | |
| 310 | 310 | writes := make(chan []byte, 1) | |
| 311 | 311 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 312 | 312 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -316,18 +316,26 @@ func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) { | |||
| 316 | 316 | d.handleInput(sess, ac, []byte("\x1bu")) | |
| 317 | 317 | ||
| 318 | 318 | if ac.copyMode == nil { | |
| 319 | - | t.Fatal("copy mode not entered") | |
| 319 | + | t.Fatal("scrollback mode not entered") | |
| 320 | 320 | } | |
| 321 | 321 | select { | |
| 322 | 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 | 324 | default: | |
| 325 | 325 | } | |
| 326 | 326 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 327 | 327 | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 328 | 328 | 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) | |
| 331 | 339 | } | |
| 332 | 340 | } | |
| 333 | 341 | ||
| @@ -361,12 +369,23 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 361 | 369 | live := awaitFrame(t, sends, ports.MsgOutput) | |
| 362 | 370 | liveMsg, err := ports.UnmarshalOutput(live.Payload) | |
| 363 | 371 | 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)) | |
| 366 | 377 | } | |
| 367 | 378 | if !strings.Contains(string(liveMsg.Data), "live") { | |
| 368 | 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 | 391 | func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |
| @@ -1185,7 +1204,7 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 1185 | 1204 | win.size = domain.Size{Cols: 12, Rows: 2} | |
| 1186 | 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 | 1209 | require.Equal(t, 12, frame.Width) | |
| 1191 | 1210 | require.Equal(t, 3, frame.Height) | |
| @@ -2013,7 +2032,7 @@ func mustOutputData(t *testing.T, sends chan ports.Frame) []byte { | |||
| 2013 | 2032 | return out.Data | |
| 2014 | 2033 | } | |
| 2015 | 2034 | ||
| 2016 | - | func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) { | |
| 2035 | + | func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |
| 2017 | 2036 | writes := make(chan []byte, 4) | |
| 2018 | 2037 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 2019 | 2038 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -2021,9 +2040,10 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) { | |||
| 2021 | 2040 | win.screen.Write([]byte("live")) | |
| 2022 | 2041 | ||
| 2023 | 2042 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 2024 | - | mustOutputData(t, sends) | |
| 2043 | + | data := mustOutputData(t, sends) | |
| 2025 | 2044 | require.NotNil(t, ac.copyMode) | |
| 2026 | 2045 | require.Equal(t, 19, ac.copyMode.Cursor) | |
| 2046 | + | require.Contains(t, string(data), "[SCROLL]") | |
| 2027 | 2047 | ||
| 2028 | 2048 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 2029 | 2049 | mustOutputData(t, sends) | |
| @@ -306,7 +306,7 @@ func testRow(text string) []renderer.Cell { | |||||
| 306 | return cells | 306 | 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.Data | 2032 | 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) | ||