detached 7cc07d9

Merge pull request #58 from bnema/fix/scrollback-status-docs

fix(copy): label passive scrollback mode
commit
7cc07d92f36e562e0adf6d729920ff7a62017ebc
author
bnema · · 2026-07-09 16:25 UTC
committer
GitHub · · 2026-07-09 16:25 UTC
merge of
974351a + 8187d66 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +14 -14
M README.md +3 -3
@@ -194,9 +194,9 @@ Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused
194194
195195 The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
196196
197-## Visual mode and copy
197+## Scrollback mode and copy
198198
199-Visual mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel.
199+Scrollback mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel. Passive browsing is labeled `[SCROLL]`; selecting lines changes the status to `[SELECT]`. Scrolling back to the bottom exits to the live pane.
200200
201201 | Key | Action |
202202 |---|---|
@@ -207,7 +207,7 @@ Visual mode freezes a view over history while the program keeps running undernea
207207 | y or Enter | copy selection and exit |
208208 | q, Esc, Ctrl-C | exit without copying |
209209
210-Copy uses OSC 52 clipboard sequences when supported by your terminal.
210+Copy uses OSC 52 clipboard sequences when supported by your terminal. After a successful copy, the bottom bar briefly shows `copied N chars to clipboard`.
211211
212212 ## Development
213213
@@ -194,9 +194,9 @@ Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused
194 194
195 The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.195 The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
196 196
197-## Visual mode and copy197+## Scrollback mode and copy
198 198
199-Visual mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel.199+Scrollback mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel. Passive browsing is labeled `[SCROLL]`; selecting lines changes the status to `[SELECT]`. Scrolling back to the bottom exits to the live pane.
200 200
201 | Key | Action |201 | Key | Action |
202 |---|---|202 |---|---|
@@ -207,7 +207,7 @@ Visual mode freezes a view over history while the program keeps running undernea
207 | y or Enter | copy selection and exit |207 | y or Enter | copy selection and exit |
208 | q, Esc, Ctrl-C | exit without copying |208 | q, Esc, Ctrl-C | exit without copying |
209 209
210-Copy uses OSC 52 clipboard sequences when supported by your terminal.210+Copy uses OSC 52 clipboard sequences when supported by your terminal. After a successful copy, the bottom bar briefly shows `copied N chars to clipboard`.
211 211
212 ## Development212 ## Development
213 213
M internal/usecase/copy/mode.go +1 -1
@@ -338,7 +338,7 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
338338 for i := range row {
339339 row[i] = renderer.BlankCell()
340340 }
341- text := " [VISUAL] "
341+ text := " [SCROLL] "
342342 if m.Selecting {
343343 text = " [SELECT] "
344344 }
@@ -338,7 +338,7 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
338 for i := range row {338 for i := range row {
339 row[i] = renderer.BlankCell()339 row[i] = renderer.BlankCell()
340 }340 }
341- text := " [VISUAL] "341+ text := " [SCROLL] "
342 if m.Selecting {342 if m.Selecting {
343 text = " [SELECT] "343 text = " [SELECT] "
344 }344 }
M internal/usecase/copy/mode_test.go +4 -4
@@ -213,14 +213,14 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
213213 m := NewMode(s)
214214
215215 frame := m.Render(s)
216- if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") {
217- t.Fatalf("passive status = %q, want [VISUAL] with N/M and no scroll/select label", got)
216+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SELECT]") {
217+ t.Fatalf("passive status = %q, want [SCROLL] with N/M and no visual/select label", got)
218218 }
219219
220220 m.ToggleSelection()
221221 frame = m.Render(s)
222- if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") {
223- t.Fatalf("selection status = %q, want [SELECT] with N/M and no [VISUAL]", got)
222+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[VISUAL]") {
223+ t.Fatalf("selection status = %q, want [SELECT] with N/M and no passive label", got)
224224 }
225225 }
226226
@@ -213,14 +213,14 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
213 m := NewMode(s)213 m := NewMode(s)
214 214
215 frame := m.Render(s)215 frame := m.Render(s)
216- if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") {216+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SELECT]") {
217- t.Fatalf("passive status = %q, want [VISUAL] with N/M and no scroll/select label", got)217+ t.Fatalf("passive status = %q, want [SCROLL] with N/M and no visual/select label", got)
218 }218 }
219 219
220 m.ToggleSelection()220 m.ToggleSelection()
221 frame = m.Render(s)221 frame = m.Render(s)
222- if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") {222+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[VISUAL]") {
223- t.Fatalf("selection status = %q, want [SELECT] with N/M and no [VISUAL]", got)223+ t.Fatalf("selection status = %q, want [SELECT] with N/M and no passive label", got)
224 }224 }
225 }225 }
226 226
M internal/usecase/daemon/copymode_test.go +3 -3
@@ -105,7 +105,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
105105 require.Equal(t, 12, frame.Width)
106106 require.Equal(t, 5, frame.Height)
107107 require.Equal(t, " 1 ", rowText(frame.Row(0)))
108- require.Contains(t, rowText(frame.Row(4)), "[VISUAL]")
108+ require.Contains(t, rowText(frame.Row(4)), "[SCROLL]")
109109 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
110110 }
111111
@@ -131,8 +131,8 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
131131 out := awaitFrame(t, sends, ports.MsgOutput)
132132 msg, err := ports.UnmarshalOutput(out.Payload)
133133 require.NoError(t, err)
134- if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[COPY]") {
135- t.Fatalf("visual mode paint = %q, want [VISUAL] without [COPY]", got)
134+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") || strings.Contains(got, "[COPY]") {
135+ t.Fatalf("passive scrollback paint = %q, want [SCROLL] without [SELECT]/[COPY]", got)
136136 }
137137
138138 d.handleInput(sess, ac, []byte(" "))
@@ -105,7 +105,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
105 require.Equal(t, 12, frame.Width)105 require.Equal(t, 12, frame.Width)
106 require.Equal(t, 5, frame.Height)106 require.Equal(t, 5, frame.Height)
107 require.Equal(t, " 1 ", rowText(frame.Row(0)))107 require.Equal(t, " 1 ", rowText(frame.Row(0)))
108- require.Contains(t, rowText(frame.Row(4)), "[VISUAL]")108+ require.Contains(t, rowText(frame.Row(4)), "[SCROLL]")
109 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)109 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
110 }110 }
111 111
@@ -131,8 +131,8 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
131 out := awaitFrame(t, sends, ports.MsgOutput)131 out := awaitFrame(t, sends, ports.MsgOutput)
132 msg, err := ports.UnmarshalOutput(out.Payload)132 msg, err := ports.UnmarshalOutput(out.Payload)
133 require.NoError(t, err)133 require.NoError(t, err)
134- if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[COPY]") {134+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") || strings.Contains(got, "[COPY]") {
135- t.Fatalf("visual mode paint = %q, want [VISUAL] without [COPY]", got)135+ t.Fatalf("passive scrollback paint = %q, want [SCROLL] without [SELECT]/[COPY]", got)
136 }136 }
137 137
138 d.handleInput(sess, ac, []byte(" "))138 d.handleInput(sess, ac, []byte(" "))
M internal/usecase/daemon/input_test.go +1 -1
@@ -652,7 +652,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
652652 data := mustOutputData(t, sends)
653653 require.NotNil(t, ac.overlays.copyMode)
654654 require.Equal(t, 19, ac.overlays.copyMode.Cursor)
655- require.Contains(t, string(data), "[VISUAL]")
655+ require.Contains(t, string(data), "[SCROLL]")
656656
657657 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
658658 mustOutputData(t, sends)
@@ -652,7 +652,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
652 data := mustOutputData(t, sends)652 data := mustOutputData(t, sends)
653 require.NotNil(t, ac.overlays.copyMode)653 require.NotNil(t, ac.overlays.copyMode)
654 require.Equal(t, 19, ac.overlays.copyMode.Cursor)654 require.Equal(t, 19, ac.overlays.copyMode.Cursor)
655- require.Contains(t, string(data), "[VISUAL]")655+ require.Contains(t, string(data), "[SCROLL]")
656 656
657 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))657 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
658 mustOutputData(t, sends)658 mustOutputData(t, sends)
M internal/usecase/daemon/render_test.go +2 -2
@@ -669,8 +669,8 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
669669 prepareScreen: func(sess *session) {
670670 sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
671671 },
672- visible: "[VISUAL]",
673- notVisible: "[VISUAL]",
672+ visible: "[SCROLL]",
673+ notVisible: "[SCROLL]",
674674 wantRestored: "live",
675675 },
676676 }
@@ -669,8 +669,8 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
669 prepareScreen: func(sess *session) {669 prepareScreen: func(sess *session) {
670 sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)670 sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
671 },671 },
672- visible: "[VISUAL]",672+ visible: "[SCROLL]",
673- notVisible: "[VISUAL]",673+ notVisible: "[SCROLL]",
674 wantRestored: "live",674 wantRestored: "live",
675 },675 },
676 }676 }

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