Skip to content

Commit f993b87

Browse files
jongioCopilot
andcommitted
feat(tui): add fullscreen preview zoom (z)
Add a fullscreen preview mode toggled with z that expands the preview to fill the content area and hides the session list, making long conversations easier to read. Press z again or Esc to return to the split layout. Paging with PgUp/PgDn works in fullscreen, and toggling on shows the preview even when the split preview is hidden. Closes #233 Co-authored-by: Copilot App <[email protected]>
1 parent 1b8c596 commit f993b87

7 files changed

Lines changed: 156 additions & 22 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess
2424
- **Sorting** (`s` / `S`) — 5 fields (updated, folder, name, created, turns) with toggleable direction
2525
- **Grouping (pivot) modes** (`Tab`) — flat, folder, repo, branch, date — displayed as collapsible trees with session counts
2626
- **Time range filtering** (`1``4`) — 1 hour, 1 day, 7 days, all
27-
- **Preview panel** (`p`) — metadata, chat-style conversation bubbles, checkpoints (up to 5), files (up to 5), refs (up to 5), scroll indicators. Toggle conversation sort order with `o`. Click the session ID row to copy it to clipboard
27+
- **Preview panel** (`p`) — metadata, chat-style conversation bubbles, checkpoints (up to 5), files (up to 5), refs (up to 5), scroll indicators. Toggle conversation sort order with `o`. Press `z` to view the preview fullscreen. Click the session ID row to copy it to clipboard
2828
- **Copy session ID** (`c`) — copy the selected session's ID to the system clipboard. Also available by clicking the ID row in the preview pane
2929
- **Copy resume command** (`Y`) — copy the selected session's full resume command to the system clipboard. With a multi-select active, copies one resume command per selected session, one per line
3030
- **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer on Windows, Finder on macOS, the default file manager on Linux)
@@ -218,6 +218,7 @@ Add `--json` (`dispatch doctor --json`) to print the same checks as a single JSO
218218
| `Tab` | Cycle grouping mode |
219219
| `p` | Toggle preview panel |
220220
| `P` | Cycle preview position (right → bottom → left → top) |
221+
| `z` | Toggle fullscreen preview |
221222
| `v` | View plan in preview pane |
222223
| `o` | Toggle conversation sort order (oldest/newest first) |
223224
| `c` | Copy session ID to clipboard |

docs/keybindings.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@
203203
- Behavior: Copies the selected session's resume command to the system clipboard using the same launch settings as Dispatch.
204204
- Condition: Only when a session is selected
205205

206+
20f. **z** → Toggle Fullscreen Preview
207+
- File: internal\tui\keys.go
208+
- Code: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "fullscreen preview"))
209+
- Handler: internal\tui\model.go (previewFullscreen toggle), internal\tui\layout.go (recalcLayout)
210+
- Behavior: Expands the preview to fill the entire content area and hides the session list so a long conversation is easier to read. Press z again or Esc to restore the split layout. PgUp/PgDn scrolling still works, and toggling on shows the preview even when the split preview is hidden.
211+
- Condition: In session list view
212+
206213
21. **PgUp (Page Up)** → Preview Panel Scroll Up
207214
- File: internal\tui\keys.go (line 85)
208215
- Code: key.NewBinding(key.WithKeys("pgup"))

internal/tui/keys.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type keyMap struct {
2727
Pivot key.Binding
2828
PivotOrder key.Binding
2929
Preview key.Binding
30+
PreviewFullscreen key.Binding
3031
Reindex key.Binding
3132
Help key.Binding
3233
Config key.Binding
@@ -73,7 +74,7 @@ type keyMap struct {
7374

7475
// ShortHelp returns a compact set of key bindings for the mini help bar.
7576
func (k keyMap) ShortHelp() []key.Binding {
76-
return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.ViewPlan, k.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.Tags, k.Alias, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit}
77+
return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.PreviewFullscreen, k.ViewPlan, k.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.Tags, k.Alias, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit}
7778
}
7879

7980
// FullHelp returns grouped key bindings for the expanded help view.
@@ -83,7 +84,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
8384
{k.Space, k.LaunchAll, k.SelectAll, k.DeselectAll, k.ShiftUp, k.ShiftDown},
8485
{k.Search, k.Escape, k.Filter},
8586
{k.Sort, k.SortOrder, k.Pivot, k.PivotOrder, k.ExpandCollapseAll},
86-
{k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config},
87+
{k.Preview, k.PreviewFullscreen, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config},
8788
{k.Hide, k.ToggleHidden, k.Star, k.Note, k.Tags, k.Alias, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted},
8889
{k.TimeRange1, k.TimeRange2, k.TimeRange3, k.TimeRange4},
8990
{k.Help, k.CmdPalette, k.Quit},
@@ -112,6 +113,7 @@ func defaultKeyMap() keyMap {
112113
Pivot: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "cycle pivot")),
113114
PivotOrder: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "reverse pivot order")),
114115
Preview: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "toggle preview")),
116+
PreviewFullscreen: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "fullscreen preview")),
115117
Reindex: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "rebuild index")),
116118
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
117119
Config: key.NewBinding(key.WithKeys(","), key.WithHelp(",", "settings")),
@@ -185,6 +187,7 @@ func keybindingEntries(km *keyMap) []keybindingEntry {
185187
{"pivot", &km.Pivot},
186188
{"pivot_order", &km.PivotOrder},
187189
{"preview", &km.Preview},
190+
{"preview_fullscreen", &km.PreviewFullscreen},
188191
{"reindex", &km.Reindex},
189192
{"help", &km.Help},
190193
{"config", &km.Config},

internal/tui/keys_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ func TestKeybindingActionNames(t *testing.T) {
184184
}
185185

186186
func TestApplyKeybindingOverrides_MatchesRemappedKey(t *testing.T) {
187-
km, _ := applyKeybindingOverrides(defaultKeyMap(), map[string]string{"search": "z"})
188-
if !key.Matches(tea.KeyPressMsg{Code: 'z'}, km.Search) {
189-
t.Error("remapped search binding should match 'z'")
187+
km, _ := applyKeybindingOverrides(defaultKeyMap(), map[string]string{"search": "u"})
188+
if !key.Matches(tea.KeyPressMsg{Code: 'u'}, km.Search) {
189+
t.Error("remapped search binding should match 'u'")
190190
}
191191
if key.Matches(tea.KeyPressMsg{Code: '/'}, km.Search) {
192192
t.Error("search should no longer match its old '/' key")

internal/tui/layout.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ func (m *Model) recalcLayout() {
4343
isHorizontal := pos == config.PreviewPositionRight || pos == config.PreviewPositionLeft
4444
isVertical := pos == config.PreviewPositionTop || pos == config.PreviewPositionBottom
4545

46-
if m.showPreview {
46+
if m.previewFullscreen {
47+
previewW = m.width
48+
previewH = contentH
49+
listW = 0
50+
listH = 0
51+
} else if m.showPreview {
4752
if isHorizontal && m.width >= styles.PreviewMinWidth {
4853
previewW = int(float64(m.width) * styles.PreviewWidthRatio)
4954
previewH = contentH

internal/tui/model.go

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -331,19 +331,20 @@ type Model struct {
331331
spinner spinner.Model
332332

333333
// UI toggles.
334-
showPreview bool
335-
previewPosition string // "right", "bottom", "left", "top"
336-
showHidden bool
337-
hiddenSet map[string]struct{} // session ID → struct{} for fast hidden-session lookup
338-
favoritedSet map[string]struct{} // session ID → struct{} for fast favorited-session lookup
339-
notesSet map[string]struct{} // session ID → struct{} for fast note-existence lookup
340-
tagsSet map[string]struct{}
341-
showFavorited bool
342-
activeView string // name of the active named view (empty means Default)
343-
reindexing bool
344-
reindexLog []string // log lines streamed from chronicle reindex
345-
reindexVP viewport.Model // scrollable viewport for reindex overlay
346-
reindexCancel *components.ReindexHandle // cancel handle for running reindex
334+
showPreview bool
335+
previewFullscreen bool
336+
previewPosition string // "right", "bottom", "left", "top"
337+
showHidden bool
338+
hiddenSet map[string]struct{} // session ID → struct{} for fast hidden-session lookup
339+
favoritedSet map[string]struct{} // session ID → struct{} for fast favorited-session lookup
340+
notesSet map[string]struct{} // session ID → struct{} for fast note-existence lookup
341+
tagsSet map[string]struct{}
342+
showFavorited bool
343+
activeView string // name of the active named view (empty means Default)
344+
reindexing bool
345+
reindexLog []string // log lines streamed from chronicle reindex
346+
reindexVP viewport.Model // scrollable viewport for reindex overlay
347+
reindexCancel *components.ReindexHandle // cancel handle for running reindex
347348

348349
// Focused sub-models.
349350
click clickState
@@ -1234,6 +1235,12 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
12341235
m.preview.ExitPlanView()
12351236
return m, nil
12361237
}
1238+
// Exit fullscreen preview back to the split layout.
1239+
if m.previewFullscreen {
1240+
m.previewFullscreen = false
1241+
m.recalcLayout()
1242+
return m, nil
1243+
}
12371244
// Clear active search query when Escape is pressed in the session list.
12381245
if m.filter.Query != "" || m.searchFilter.HasTokens() {
12391246
m.filter.Query = ""
@@ -1405,6 +1412,15 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
14051412
}
14061413
return m, nil
14071414

1415+
case key.Matches(msg, keys.PreviewFullscreen):
1416+
m.previewFullscreen = !m.previewFullscreen
1417+
m.recalcLayout()
1418+
if m.previewFullscreen {
1419+
m.detailVersion++
1420+
return m, m.loadSelectedDetailCmd()
1421+
}
1422+
return m, nil
1423+
14081424
case key.Matches(msg, keys.PreviewPosition):
14091425
m.cyclePreviewPosition()
14101426
m.recalcLayout()
@@ -1419,15 +1435,15 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
14191435
return m, nil
14201436

14211437
case key.Matches(msg, keys.PreviewScrollUp):
1422-
if m.showPreview {
1438+
if m.showPreview || m.previewFullscreen {
14231439
before := m.preview.ScrollOffset()
14241440
m.preview.PageUp()
14251441
slog.Debug("preview scroll up", "before", before, "after", m.preview.ScrollOffset())
14261442
}
14271443
return m, nil
14281444

14291445
case key.Matches(msg, keys.PreviewScrollDown):
1430-
if m.showPreview {
1446+
if m.showPreview || m.previewFullscreen {
14311447
before := m.preview.ScrollOffset()
14321448
m.preview.PageDown()
14331449
slog.Debug("preview scroll down", "before", before, "after", m.preview.ScrollOffset())
@@ -2587,6 +2603,11 @@ func (m Model) renderMainView() string {
25872603
// rendering and hit-testing always agree on panel positions/sizes.
25882604
l := m.layout
25892605

2606+
// Fullscreen preview fills the entire content area; the list is hidden.
2607+
if m.previewFullscreen {
2608+
return strings.Join([]string{header, badges, sep, m.preview.View(), footer}, "\n")
2609+
}
2610+
25902611
var content string
25912612
hasPreview := l.previewWidth > 0 && l.previewHeight > 0
25922613

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
tea "charm.land/bubbletea/v2"
8+
)
9+
10+
// TestPreviewFullscreen_ToggleAndLayout verifies that pressing "z" enters a
11+
// fullscreen preview (list hidden, preview fills the content area) and that
12+
// pressing "z" again restores the split layout.
13+
func TestPreviewFullscreen_ToggleAndLayout(t *testing.T) {
14+
m := newTestModelWithSize(120, 40)
15+
m.showPreview = true
16+
m.recalcLayout()
17+
18+
if m.previewFullscreen {
19+
t.Fatal("expected previewFullscreen false initially")
20+
}
21+
22+
res, _ := m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})
23+
rm := res.(Model)
24+
if !rm.previewFullscreen {
25+
t.Fatal("pressing z should enable fullscreen preview")
26+
}
27+
if rm.layout.previewWidth != rm.width {
28+
t.Errorf("fullscreen preview width = %d, want %d", rm.layout.previewWidth, rm.width)
29+
}
30+
if rm.layout.listWidth != 0 {
31+
t.Errorf("fullscreen list width = %d, want 0", rm.layout.listWidth)
32+
}
33+
if rm.layout.previewHeight != rm.layout.contentHeight {
34+
t.Errorf("fullscreen preview height = %d, want content height %d", rm.layout.previewHeight, rm.layout.contentHeight)
35+
}
36+
37+
res2, _ := rm.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})
38+
rm2 := res2.(Model)
39+
if rm2.previewFullscreen {
40+
t.Fatal("pressing z again should disable fullscreen preview")
41+
}
42+
if rm2.layout.listWidth == 0 {
43+
t.Error("exiting fullscreen should restore the session list width")
44+
}
45+
}
46+
47+
// TestPreviewFullscreen_EscapeExits verifies that Escape leaves fullscreen and
48+
// restores the split layout.
49+
func TestPreviewFullscreen_EscapeExits(t *testing.T) {
50+
m := newTestModelWithSize(120, 40)
51+
m.showPreview = true
52+
m.recalcLayout()
53+
54+
res, _ := m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})
55+
rm := res.(Model)
56+
if !rm.previewFullscreen {
57+
t.Fatal("expected fullscreen after z")
58+
}
59+
60+
res2, _ := rm.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
61+
rm2 := res2.(Model)
62+
if rm2.previewFullscreen {
63+
t.Error("esc should exit fullscreen preview")
64+
}
65+
if rm2.layout.previewWidth == rm2.width {
66+
t.Error("esc should restore the split layout (preview no longer full width)")
67+
}
68+
}
69+
70+
// TestPreviewFullscreen_WorksWithPreviewOff verifies that fullscreen can be
71+
// entered even when the split preview is toggled off, that paging does not exit
72+
// fullscreen, and that the view renders non-empty content.
73+
func TestPreviewFullscreen_WorksWithPreviewOff(t *testing.T) {
74+
m := newTestModelWithSize(120, 40)
75+
m.showPreview = false
76+
m.recalcLayout()
77+
78+
res, _ := m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})
79+
rm := res.(Model)
80+
if !rm.previewFullscreen {
81+
t.Fatal("expected fullscreen after z even with preview toggled off")
82+
}
83+
if rm.layout.previewWidth != rm.width {
84+
t.Errorf("fullscreen preview width = %d, want %d", rm.layout.previewWidth, rm.width)
85+
}
86+
87+
res2, _ := rm.Update(tea.KeyPressMsg{Code: tea.KeyPgDown})
88+
rm2 := res2.(Model)
89+
if !rm2.previewFullscreen {
90+
t.Error("paging should not exit fullscreen preview")
91+
}
92+
93+
out := rm2.renderMainView()
94+
if strings.TrimSpace(out) == "" {
95+
t.Error("fullscreen view should render non-empty content")
96+
}
97+
}

0 commit comments

Comments
 (0)