Skip to content

Commit b7e49dd

Browse files
wbrezaCopilotjongio
authored
feat: add configurable preview position (right/bottom/left/top) (#18)
* feat: add configurable preview position (right/bottom/left/top) Add preview_position config setting to control where the session detail panel displays. Users can set it in config.json or cycle through positions at runtime with the P key (persisted automatically). - Add PreviewPosition field to Config with EffectivePreviewPosition() - Update recalcLayout/renderMainView for horizontal and vertical splits - Add PreviewHeightRatio/PreviewMinHeight style constants - Add P keybinding to cycle positions with auto-persist - Add preview position to Settings panel (config panel cycling) - Add isOverPreview() for position-aware mouse hit detection - Add comprehensive tests: config, configpanel, layout, mouse, keybinding Closes #17 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: extract layout.go and remove layout dimension duplication Move recalcLayout() and layout helpers to internal/tui/layout.go. Store computed layout dimensions on model struct so renderMainView() reads pre-computed values instead of recomputing independently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add preview position to README, web docs, and screenshots Document P key binding for cycling preview positions (right/bottom/ left/top). Add screenshot captures for all 4 positions. Update features.astro, config.astro, and keys.astro. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address preflight issues Convert if/else chain on previewPosition to tagged switch (staticcheck QF1003). Align struct field formatting (gofumpt). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Carousel component and regenerate screenshots Add a reusable Carousel.astro component for cycling through screenshots one at a time with prev/next arrows and dot indicators. Wrap the Preview Position screenshots in the carousel. Regenerate all 180 screenshots across 5 themes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jon Gallant <2163001+jongio@users.noreply.github.com>
1 parent 74884c4 commit b7e49dd

168 files changed

Lines changed: 985 additions & 108 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ dispatch
180180
| `S` | Toggle sort direction |
181181
| `Tab` | Cycle grouping mode |
182182
| `p` | Toggle preview panel |
183+
| `P` | Cycle preview position (right → bottom → left → top) |
183184
| `PgUp` / `PgDn` | Scroll preview |
184185
| `r` | Refresh session store |
185186
| `,` | Open settings panel |
@@ -248,6 +249,7 @@ Configuration is stored in the platform-specific config directory:
248249
| `default_sort` | string | `"updated"` | Sort field: `updated`, `created`, `turns`, `name`, `folder` |
249250
| `default_pivot` | string | `"folder"` | Grouping: `none`, `folder`, `repo`, `branch`, `date` |
250251
| `show_preview` | bool | `true` | Show preview pane on startup |
252+
| `preview_position` | string | `"right"` | Position of the preview pane: `right`, `bottom`, `left`, `top` |
251253
| `max_sessions` | int | `100` | Maximum sessions to load |
252254
| `yoloMode` | bool | `false` | Pass `--allow-all` to Copilot CLI (auto-confirm commands) |
253255
| `agent` | string | `""` | Pass `--agent <name>` to Copilot CLI |
@@ -286,6 +288,7 @@ When `launch_mode` is `"pane"`, the `pane_direction` value maps to Windows Termi
286288
"default_sort": "updated",
287289
"default_pivot": "folder",
288290
"show_preview": true,
291+
"preview_position": "right",
289292
"max_sessions": 100,
290293
"yoloMode": false,
291294
"agent": "",

‎internal/config/config.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ type Config struct {
130130
// format. Users can paste any WT scheme JSON directly here.
131131
Schemes []styles.ColorScheme `json:"schemes,omitempty"`
132132

133+
// PreviewPosition controls where the session detail/preview panel
134+
// is displayed relative to the session list.
135+
// Valid values: "right", "bottom", "left", "top".
136+
// Default is "right" when unset.
137+
PreviewPosition string `json:"preview_position,omitempty"`
138+
133139
// ConversationNewestFirst controls the turn display order in the
134140
// preview panel's Conversation section. When false (default), turns
135141
// are shown oldest-first (ascending by TurnIndex). When true, turns
@@ -170,6 +176,18 @@ const (
170176
PaneDirectionUp = "up"
171177
)
172178

179+
// PreviewPosition constants control where the preview panel is displayed.
180+
const (
181+
// PreviewPositionRight places the preview to the right of the session list.
182+
PreviewPositionRight = "right"
183+
// PreviewPositionBottom places the preview below the session list.
184+
PreviewPositionBottom = "bottom"
185+
// PreviewPositionLeft places the preview to the left of the session list.
186+
PreviewPositionLeft = "left"
187+
// PreviewPositionTop places the preview above the session list.
188+
PreviewPositionTop = "top"
189+
)
190+
173191
// EffectivePaneDirection returns the configured pane direction, defaulting
174192
// to "auto" when unset.
175193
func (c *Config) EffectivePaneDirection() string {
@@ -179,6 +197,17 @@ func (c *Config) EffectivePaneDirection() string {
179197
return PaneDirectionAuto
180198
}
181199

200+
// EffectivePreviewPosition returns the configured preview panel position,
201+
// defaulting to "right" when unset or invalid.
202+
func (c *Config) EffectivePreviewPosition() string {
203+
switch c.PreviewPosition {
204+
case PreviewPositionRight, PreviewPositionBottom, PreviewPositionLeft, PreviewPositionTop:
205+
return c.PreviewPosition
206+
default:
207+
return PreviewPositionRight
208+
}
209+
}
210+
182211
// defaultAttentionThreshold is used when AttentionThreshold is empty or unparseable.
183212
const defaultAttentionThreshold = 15 * time.Minute
184213

‎internal/config/config_test.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ func TestDefaultValues(t *testing.T) {
6060
if !cfg.WorkspaceRecovery {
6161
t.Error("WorkspaceRecovery should default to true")
6262
}
63+
if cfg.PreviewPosition != "" {
64+
t.Errorf("PreviewPosition = %q, want empty", cfg.PreviewPosition)
65+
}
6366
}
6467

6568
// ---------------------------------------------------------------------------
@@ -82,6 +85,7 @@ func TestConfigJSONRoundTrip(t *testing.T) {
8285
ExcludedDirs: []string{"/tmp", "/var"},
8386
CustomCommand: "ghcs --resume {sessionId} --custom",
8487
HiddenSessions: []string{"sess-1", "sess-2"},
88+
PreviewPosition: "bottom",
8589
}
8690

8791
data, err := json.Marshal(original)
@@ -146,6 +150,9 @@ func TestConfigJSONRoundTrip(t *testing.T) {
146150
t.Errorf("HiddenSessions[%d] = %q, want %q", i, restored.HiddenSessions[i], original.HiddenSessions[i])
147151
}
148152
}
153+
if restored.PreviewPosition != original.PreviewPosition {
154+
t.Errorf("PreviewPosition = %q, want %q", restored.PreviewPosition, original.PreviewPosition)
155+
}
149156
}
150157

151158
func TestWorkspaceRecoveryJSONFalse(t *testing.T) {
@@ -1035,3 +1042,72 @@ func TestLoad_SanitizesUnsafeShellOnDisk(t *testing.T) {
10351042
t.Errorf("DefaultTerminal = %q, want empty after sanitize", cfg.DefaultTerminal)
10361043
}
10371044
}
1045+
1046+
// ---------------------------------------------------------------------------
1047+
// EffectivePreviewPosition tests
1048+
// ---------------------------------------------------------------------------
1049+
1050+
func TestEffectivePreviewPosition_Default(t *testing.T) {
1051+
cfg := Default()
1052+
if got := cfg.EffectivePreviewPosition(); got != PreviewPositionRight {
1053+
t.Errorf("default config: got %q, want %q", got, PreviewPositionRight)
1054+
}
1055+
}
1056+
1057+
func TestEffectivePreviewPosition_EmptyDefaultsToRight(t *testing.T) {
1058+
cfg := &Config{}
1059+
if got := cfg.EffectivePreviewPosition(); got != PreviewPositionRight {
1060+
t.Errorf("empty PreviewPosition: got %q, want %q", got, PreviewPositionRight)
1061+
}
1062+
}
1063+
1064+
func TestEffectivePreviewPosition_InvalidDefaultsToRight(t *testing.T) {
1065+
cfg := &Config{PreviewPosition: "invalid"}
1066+
if got := cfg.EffectivePreviewPosition(); got != PreviewPositionRight {
1067+
t.Errorf("invalid PreviewPosition: got %q, want %q", got, PreviewPositionRight)
1068+
}
1069+
}
1070+
1071+
func TestEffectivePreviewPosition_ExplicitValues(t *testing.T) {
1072+
tests := []struct {
1073+
pos string
1074+
want string
1075+
}{
1076+
{PreviewPositionRight, PreviewPositionRight},
1077+
{PreviewPositionBottom, PreviewPositionBottom},
1078+
{PreviewPositionLeft, PreviewPositionLeft},
1079+
{PreviewPositionTop, PreviewPositionTop},
1080+
}
1081+
for _, tt := range tests {
1082+
cfg := &Config{PreviewPosition: tt.pos}
1083+
if got := cfg.EffectivePreviewPosition(); got != tt.want {
1084+
t.Errorf("PreviewPosition=%q: got %q, want %q", tt.pos, got, tt.want)
1085+
}
1086+
}
1087+
}
1088+
1089+
func TestEffectivePreviewPosition_JSONRoundTrip(t *testing.T) {
1090+
jsonStr := `{"preview_position": "bottom"}`
1091+
cfg := Default()
1092+
if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
1093+
t.Fatal(err)
1094+
}
1095+
if got := cfg.EffectivePreviewPosition(); got != PreviewPositionBottom {
1096+
t.Errorf("JSON preview_position: got %q, want %q", got, PreviewPositionBottom)
1097+
}
1098+
}
1099+
1100+
func TestPreviewPositionConstants(t *testing.T) {
1101+
if PreviewPositionRight != "right" {
1102+
t.Errorf("PreviewPositionRight = %q, want 'right'", PreviewPositionRight)
1103+
}
1104+
if PreviewPositionBottom != "bottom" {
1105+
t.Errorf("PreviewPositionBottom = %q, want 'bottom'", PreviewPositionBottom)
1106+
}
1107+
if PreviewPositionLeft != "left" {
1108+
t.Errorf("PreviewPositionLeft = %q, want 'left'", PreviewPositionLeft)
1109+
}
1110+
if PreviewPositionTop != "top" {
1111+
t.Errorf("PreviewPositionTop = %q, want 'top'", PreviewPositionTop)
1112+
}
1113+
}

‎internal/tui/components/configpanel.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const (
3030
cfgCustomCommand
3131
cfgTheme
3232
cfgWorkspaceRecovery
33+
cfgPreviewPosition
3334
cfgFieldCount
3435
)
3536

@@ -50,6 +51,7 @@ type ConfigPanel struct {
5051
customCommand string
5152
theme string // active color scheme name ("auto" or a scheme name)
5253
workspaceRecovery bool
54+
previewPosition string // "right", "bottom", "left", "top"
5355

5456
// Available options for cycling.
5557
terminals []string
@@ -89,6 +91,7 @@ type ConfigValues struct {
8991
CustomCommand string
9092
Theme string
9193
WorkspaceRecovery bool
94+
PreviewPosition string
9295
}
9396

9497
// SetValues loads the config panel state from external values.
@@ -103,6 +106,7 @@ func (c *ConfigPanel) SetValues(v ConfigValues) {
103106
c.customCommand = v.CustomCommand
104107
c.theme = v.Theme
105108
c.workspaceRecovery = v.WorkspaceRecovery
109+
c.previewPosition = v.PreviewPosition
106110
}
107111

108112
// Values returns the current state of all editable fields.
@@ -118,6 +122,7 @@ func (c *ConfigPanel) Values() ConfigValues {
118122
CustomCommand: c.customCommand,
119123
Theme: c.theme,
120124
WorkspaceRecovery: c.workspaceRecovery,
125+
PreviewPosition: c.previewPosition,
121126
}
122127
}
123128

@@ -206,6 +211,8 @@ func (c *ConfigPanel) HandleEnter() tea.Cmd {
206211
c.theme = c.cycleTheme(c.theme)
207212
case cfgWorkspaceRecovery:
208213
c.workspaceRecovery = !c.workspaceRecovery
214+
case cfgPreviewPosition:
215+
c.previewPosition = cyclePreviewPosition(c.previewPosition)
209216
default:
210217
// cfgFieldCount is a sentinel; no action needed.
211218
}
@@ -277,6 +284,7 @@ func (c ConfigPanel) View() string {
277284
{"Custom Command", stringDisplay(c.customCommand), false},
278285
{"Theme", themeDisplay(c.theme), false},
279286
{"Crash Recovery", boolDisplay(c.workspaceRecovery), false},
287+
{"Preview Position", previewPositionDisplay(c.previewPosition), false},
280288
}
281289

282290
var body strings.Builder
@@ -459,3 +467,33 @@ func (c *ConfigPanel) cycleTheme(current string) string {
459467
}
460468
return c.themeNames[0]
461469
}
470+
471+
// cyclePreviewPosition cycles through the four preview positions.
472+
func cyclePreviewPosition(current string) string {
473+
switch current {
474+
case config.PreviewPositionRight:
475+
return config.PreviewPositionBottom
476+
case config.PreviewPositionBottom:
477+
return config.PreviewPositionLeft
478+
case config.PreviewPositionLeft:
479+
return config.PreviewPositionTop
480+
case config.PreviewPositionTop:
481+
return config.PreviewPositionRight
482+
default:
483+
return config.PreviewPositionBottom
484+
}
485+
}
486+
487+
// previewPositionDisplay renders the preview position as a user-friendly label.
488+
func previewPositionDisplay(pos string) string {
489+
switch pos {
490+
case config.PreviewPositionBottom:
491+
return styles.ConfigValueStyle.Render("Bottom")
492+
case config.PreviewPositionLeft:
493+
return styles.ConfigValueStyle.Render("Left")
494+
case config.PreviewPositionTop:
495+
return styles.ConfigValueStyle.Render("Top")
496+
default:
497+
return styles.ConfigValueStyle.Render("Right")
498+
}
499+
}

‎internal/tui/components/configpanel_test.go‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func TestConfigPanel_SetValues_RoundTrip(t *testing.T) {
3434
cp.SetValues(ConfigValues{
3535
YoloMode: true, Agent: "myagent", Model: "gpt-4", LaunchMode: "tab",
3636
Terminal: "Windows Terminal", Shell: "pwsh", CustomCommand: "my-cmd {sessionId}", Theme: "Campbell",
37-
WorkspaceRecovery: true,
37+
WorkspaceRecovery: true, PreviewPosition: "bottom",
3838
})
3939

4040
v := cp.Values()
@@ -65,6 +65,9 @@ func TestConfigPanel_SetValues_RoundTrip(t *testing.T) {
6565
if !v.WorkspaceRecovery {
6666
t.Error("workspaceRecovery should be true")
6767
}
68+
if v.PreviewPosition != "bottom" {
69+
t.Errorf("previewPosition = %q, want %q", v.PreviewPosition, "bottom")
70+
}
6871
}
6972

7073
// ---------------------------------------------------------------------------
@@ -152,6 +155,36 @@ func TestConfigPanel_HandleEnter_WorkspaceRecoveryToggle(t *testing.T) {
152155
}
153156
}
154157

158+
func TestConfigPanel_HandleEnter_PreviewPositionCycle(t *testing.T) {
159+
cp := NewConfigPanel()
160+
cp.SetValues(ConfigValues{PreviewPosition: "right"})
161+
cp.cursor = cfgPreviewPosition
162+
163+
// right → bottom
164+
cp.HandleEnter()
165+
if pos := cp.Values().PreviewPosition; pos != "bottom" {
166+
t.Errorf("after cycling from right, pos = %q, want %q", pos, "bottom")
167+
}
168+
169+
// bottom → left
170+
cp.HandleEnter()
171+
if pos := cp.Values().PreviewPosition; pos != "left" {
172+
t.Errorf("after cycling from bottom, pos = %q, want %q", pos, "left")
173+
}
174+
175+
// left → top
176+
cp.HandleEnter()
177+
if pos := cp.Values().PreviewPosition; pos != "top" {
178+
t.Errorf("after cycling from left, pos = %q, want %q", pos, "top")
179+
}
180+
181+
// top → right (wraps around)
182+
cp.HandleEnter()
183+
if pos := cp.Values().PreviewPosition; pos != "right" {
184+
t.Errorf("after cycling from top, pos = %q, want %q", pos, "right")
185+
}
186+
}
187+
155188
func TestConfigPanel_HandleEnter_LaunchModeCycle(t *testing.T) {
156189
cp := NewConfigPanel()
157190
cp.SetValues(ConfigValues{LaunchMode: "window"})
@@ -305,7 +338,7 @@ func TestConfigPanel_View_ContainsFields(t *testing.T) {
305338
cp := NewConfigPanel()
306339
cp.SetSize(80, 40)
307340
view := cp.View()
308-
for _, field := range []string{"Yolo Mode", "Agent", "Model", "Launch Mode", "Terminal", "Shell", "Custom Command", "Theme", "Crash Recovery"} {
341+
for _, field := range []string{"Yolo Mode", "Agent", "Model", "Launch Mode", "Terminal", "Shell", "Custom Command", "Theme", "Crash Recovery", "Preview Position"} {
309342
if !strings.Contains(view, field) {
310343
t.Errorf("View should contain field %q", field)
311344
}
@@ -411,3 +444,47 @@ func TestThemeDisplay(t *testing.T) {
411444
}
412445
}
413446
}
447+
448+
// ---------------------------------------------------------------------------
449+
// Preview position helpers
450+
// ---------------------------------------------------------------------------
451+
452+
func TestCyclePreviewPosition(t *testing.T) {
453+
tests := []struct {
454+
current string
455+
want string
456+
}{
457+
{"right", "bottom"},
458+
{"bottom", "left"},
459+
{"left", "top"},
460+
{"top", "right"},
461+
{"", "bottom"}, // default/empty cycles to bottom
462+
{"invalid", "bottom"}, // invalid cycles to bottom
463+
}
464+
for _, tt := range tests {
465+
got := cyclePreviewPosition(tt.current)
466+
if got != tt.want {
467+
t.Errorf("cyclePreviewPosition(%q) = %q, want %q", tt.current, got, tt.want)
468+
}
469+
}
470+
}
471+
472+
func TestPreviewPositionDisplay(t *testing.T) {
473+
tests := []struct {
474+
pos string
475+
want string
476+
}{
477+
{"right", "Right"},
478+
{"bottom", "Bottom"},
479+
{"left", "Left"},
480+
{"top", "Top"},
481+
{"", "Right"}, // default shows Right
482+
{"invalid", "Right"}, // invalid shows Right
483+
}
484+
for _, tt := range tests {
485+
got := previewPositionDisplay(tt.pos)
486+
if !strings.Contains(got, tt.want) {
487+
t.Errorf("previewPositionDisplay(%q) = %q, want to contain %q", tt.pos, got, tt.want)
488+
}
489+
}
490+
}

‎internal/tui/components/preview.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"github.com/jongio/dispatch/internal/tui/styles"
1010
)
1111

12-
// PreviewPanel renders a right-side detail panel for the selected session.
12+
// PreviewPanel renders a detail panel for the selected session.
1313
type PreviewPanel struct {
1414
detail *data.SessionDetail
1515
attentionStatus data.AttentionStatus

0 commit comments

Comments
 (0)