Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ dispatch
| `S` | Toggle sort direction |
| `Tab` | Cycle grouping mode |
| `p` | Toggle preview panel |
| `P` | Cycle preview position (right → bottom → left → top) |
| `PgUp` / `PgDn` | Scroll preview |
| `r` | Refresh session store |
| `,` | Open settings panel |
Expand Down Expand Up @@ -248,6 +249,7 @@ Configuration is stored in the platform-specific config directory:
| `default_sort` | string | `"updated"` | Sort field: `updated`, `created`, `turns`, `name`, `folder` |
| `default_pivot` | string | `"folder"` | Grouping: `none`, `folder`, `repo`, `branch`, `date` |
| `show_preview` | bool | `true` | Show preview pane on startup |
| `preview_position` | string | `"right"` | Position of the preview pane: `right`, `bottom`, `left`, `top` |
| `max_sessions` | int | `100` | Maximum sessions to load |
| `yoloMode` | bool | `false` | Pass `--allow-all` to Copilot CLI (auto-confirm commands) |
| `agent` | string | `""` | Pass `--agent <name>` to Copilot CLI |
Expand All @@ -272,6 +274,7 @@ Configuration is stored in the platform-specific config directory:
"default_sort": "updated",
"default_pivot": "folder",
"show_preview": true,
"preview_position": "right",
"max_sessions": 100,
"yoloMode": false,
"agent": "",
Expand Down
29 changes: 29 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ type Config struct {
// format. Users can paste any WT scheme JSON directly here.
Schemes []styles.ColorScheme `json:"schemes,omitempty"`

// PreviewPosition controls where the session detail/preview panel
// is displayed relative to the session list.
// Valid values: "right", "bottom", "left", "top".
// Default is "right" when unset.
PreviewPosition string `json:"preview_position,omitempty"`

// ConversationNewestFirst controls the turn display order in the
// preview panel's Conversation section. When false (default), turns
// are shown oldest-first (ascending by TurnIndex). When true, turns
Expand Down Expand Up @@ -170,6 +176,18 @@ const (
PaneDirectionUp = "up"
)

// PreviewPosition constants control where the preview panel is displayed.
const (
// PreviewPositionRight places the preview to the right of the session list.
PreviewPositionRight = "right"
// PreviewPositionBottom places the preview below the session list.
PreviewPositionBottom = "bottom"
// PreviewPositionLeft places the preview to the left of the session list.
PreviewPositionLeft = "left"
// PreviewPositionTop places the preview above the session list.
PreviewPositionTop = "top"
)

// EffectivePaneDirection returns the configured pane direction, defaulting
// to "auto" when unset.
func (c *Config) EffectivePaneDirection() string {
Expand All @@ -179,6 +197,17 @@ func (c *Config) EffectivePaneDirection() string {
return PaneDirectionAuto
}

// EffectivePreviewPosition returns the configured preview panel position,
// defaulting to "right" when unset or invalid.
func (c *Config) EffectivePreviewPosition() string {
switch c.PreviewPosition {
case PreviewPositionRight, PreviewPositionBottom, PreviewPositionLeft, PreviewPositionTop:
return c.PreviewPosition
default:
return PreviewPositionRight
}
}

// defaultAttentionThreshold is used when AttentionThreshold is empty or unparseable.
const defaultAttentionThreshold = 15 * time.Minute

Expand Down
76 changes: 76 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ func TestDefaultValues(t *testing.T) {
if !cfg.WorkspaceRecovery {
t.Error("WorkspaceRecovery should default to true")
}
if cfg.PreviewPosition != "" {
t.Errorf("PreviewPosition = %q, want empty", cfg.PreviewPosition)
}
}

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

data, err := json.Marshal(original)
Expand Down Expand Up @@ -146,6 +150,9 @@ func TestConfigJSONRoundTrip(t *testing.T) {
t.Errorf("HiddenSessions[%d] = %q, want %q", i, restored.HiddenSessions[i], original.HiddenSessions[i])
}
}
if restored.PreviewPosition != original.PreviewPosition {
t.Errorf("PreviewPosition = %q, want %q", restored.PreviewPosition, original.PreviewPosition)
}
}

func TestWorkspaceRecoveryJSONFalse(t *testing.T) {
Expand Down Expand Up @@ -1035,3 +1042,72 @@ func TestLoad_SanitizesUnsafeShellOnDisk(t *testing.T) {
t.Errorf("DefaultTerminal = %q, want empty after sanitize", cfg.DefaultTerminal)
}
}

// ---------------------------------------------------------------------------
// EffectivePreviewPosition tests
// ---------------------------------------------------------------------------

func TestEffectivePreviewPosition_Default(t *testing.T) {
cfg := Default()
if got := cfg.EffectivePreviewPosition(); got != PreviewPositionRight {
t.Errorf("default config: got %q, want %q", got, PreviewPositionRight)
}
}

func TestEffectivePreviewPosition_EmptyDefaultsToRight(t *testing.T) {
cfg := &Config{}
if got := cfg.EffectivePreviewPosition(); got != PreviewPositionRight {
t.Errorf("empty PreviewPosition: got %q, want %q", got, PreviewPositionRight)
}
}

func TestEffectivePreviewPosition_InvalidDefaultsToRight(t *testing.T) {
cfg := &Config{PreviewPosition: "invalid"}
if got := cfg.EffectivePreviewPosition(); got != PreviewPositionRight {
t.Errorf("invalid PreviewPosition: got %q, want %q", got, PreviewPositionRight)
}
}

func TestEffectivePreviewPosition_ExplicitValues(t *testing.T) {
tests := []struct {
pos string
want string
}{
{PreviewPositionRight, PreviewPositionRight},
{PreviewPositionBottom, PreviewPositionBottom},
{PreviewPositionLeft, PreviewPositionLeft},
{PreviewPositionTop, PreviewPositionTop},
}
for _, tt := range tests {
cfg := &Config{PreviewPosition: tt.pos}
if got := cfg.EffectivePreviewPosition(); got != tt.want {
t.Errorf("PreviewPosition=%q: got %q, want %q", tt.pos, got, tt.want)
}
}
}

func TestEffectivePreviewPosition_JSONRoundTrip(t *testing.T) {
jsonStr := `{"preview_position": "bottom"}`
cfg := Default()
if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
t.Fatal(err)
}
if got := cfg.EffectivePreviewPosition(); got != PreviewPositionBottom {
t.Errorf("JSON preview_position: got %q, want %q", got, PreviewPositionBottom)
}
}

func TestPreviewPositionConstants(t *testing.T) {
if PreviewPositionRight != "right" {
t.Errorf("PreviewPositionRight = %q, want 'right'", PreviewPositionRight)
}
if PreviewPositionBottom != "bottom" {
t.Errorf("PreviewPositionBottom = %q, want 'bottom'", PreviewPositionBottom)
}
if PreviewPositionLeft != "left" {
t.Errorf("PreviewPositionLeft = %q, want 'left'", PreviewPositionLeft)
}
if PreviewPositionTop != "top" {
t.Errorf("PreviewPositionTop = %q, want 'top'", PreviewPositionTop)
}
}
38 changes: 38 additions & 0 deletions internal/tui/components/configpanel.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const (
cfgCustomCommand
cfgTheme
cfgWorkspaceRecovery
cfgPreviewPosition
cfgFieldCount
)

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

// Available options for cycling.
terminals []string
Expand Down Expand Up @@ -89,6 +91,7 @@ type ConfigValues struct {
CustomCommand string
Theme string
WorkspaceRecovery bool
PreviewPosition string
}

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

// Values returns the current state of all editable fields.
Expand All @@ -118,6 +122,7 @@ func (c *ConfigPanel) Values() ConfigValues {
CustomCommand: c.customCommand,
Theme: c.theme,
WorkspaceRecovery: c.workspaceRecovery,
PreviewPosition: c.previewPosition,
}
}

Expand Down Expand Up @@ -206,6 +211,8 @@ func (c *ConfigPanel) HandleEnter() tea.Cmd {
c.theme = c.cycleTheme(c.theme)
case cfgWorkspaceRecovery:
c.workspaceRecovery = !c.workspaceRecovery
case cfgPreviewPosition:
c.previewPosition = cyclePreviewPosition(c.previewPosition)
default:
// cfgFieldCount is a sentinel; no action needed.
}
Expand Down Expand Up @@ -277,6 +284,7 @@ func (c ConfigPanel) View() string {
{"Custom Command", stringDisplay(c.customCommand), false},
{"Theme", themeDisplay(c.theme), false},
{"Crash Recovery", boolDisplay(c.workspaceRecovery), false},
{"Preview Position", previewPositionDisplay(c.previewPosition), false},
}

var body strings.Builder
Expand Down Expand Up @@ -459,3 +467,33 @@ func (c *ConfigPanel) cycleTheme(current string) string {
}
return c.themeNames[0]
}

// cyclePreviewPosition cycles through the four preview positions.
func cyclePreviewPosition(current string) string {
switch current {
case config.PreviewPositionRight:
return config.PreviewPositionBottom
case config.PreviewPositionBottom:
return config.PreviewPositionLeft
case config.PreviewPositionLeft:
return config.PreviewPositionTop
case config.PreviewPositionTop:
return config.PreviewPositionRight
default:
return config.PreviewPositionBottom
}
}

// previewPositionDisplay renders the preview position as a user-friendly label.
func previewPositionDisplay(pos string) string {
switch pos {
case config.PreviewPositionBottom:
return styles.ConfigValueStyle.Render("Bottom")
case config.PreviewPositionLeft:
return styles.ConfigValueStyle.Render("Left")
case config.PreviewPositionTop:
return styles.ConfigValueStyle.Render("Top")
default:
return styles.ConfigValueStyle.Render("Right")
}
}
81 changes: 79 additions & 2 deletions internal/tui/components/configpanel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestConfigPanel_SetValues_RoundTrip(t *testing.T) {
cp.SetValues(ConfigValues{
YoloMode: true, Agent: "myagent", Model: "gpt-4", LaunchMode: "tab",
Terminal: "Windows Terminal", Shell: "pwsh", CustomCommand: "my-cmd {sessionId}", Theme: "Campbell",
WorkspaceRecovery: true,
WorkspaceRecovery: true, PreviewPosition: "bottom",
})

v := cp.Values()
Expand Down Expand Up @@ -65,6 +65,9 @@ func TestConfigPanel_SetValues_RoundTrip(t *testing.T) {
if !v.WorkspaceRecovery {
t.Error("workspaceRecovery should be true")
}
if v.PreviewPosition != "bottom" {
t.Errorf("previewPosition = %q, want %q", v.PreviewPosition, "bottom")
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -152,6 +155,36 @@ func TestConfigPanel_HandleEnter_WorkspaceRecoveryToggle(t *testing.T) {
}
}

func TestConfigPanel_HandleEnter_PreviewPositionCycle(t *testing.T) {
cp := NewConfigPanel()
cp.SetValues(ConfigValues{PreviewPosition: "right"})
cp.cursor = cfgPreviewPosition

// right → bottom
cp.HandleEnter()
if pos := cp.Values().PreviewPosition; pos != "bottom" {
t.Errorf("after cycling from right, pos = %q, want %q", pos, "bottom")
}

// bottom → left
cp.HandleEnter()
if pos := cp.Values().PreviewPosition; pos != "left" {
t.Errorf("after cycling from bottom, pos = %q, want %q", pos, "left")
}

// left → top
cp.HandleEnter()
if pos := cp.Values().PreviewPosition; pos != "top" {
t.Errorf("after cycling from left, pos = %q, want %q", pos, "top")
}

// top → right (wraps around)
cp.HandleEnter()
if pos := cp.Values().PreviewPosition; pos != "right" {
t.Errorf("after cycling from top, pos = %q, want %q", pos, "right")
}
}

func TestConfigPanel_HandleEnter_LaunchModeCycle(t *testing.T) {
cp := NewConfigPanel()
cp.SetValues(ConfigValues{LaunchMode: "window"})
Expand Down Expand Up @@ -305,7 +338,7 @@ func TestConfigPanel_View_ContainsFields(t *testing.T) {
cp := NewConfigPanel()
cp.SetSize(80, 40)
view := cp.View()
for _, field := range []string{"Yolo Mode", "Agent", "Model", "Launch Mode", "Terminal", "Shell", "Custom Command", "Theme", "Crash Recovery"} {
for _, field := range []string{"Yolo Mode", "Agent", "Model", "Launch Mode", "Terminal", "Shell", "Custom Command", "Theme", "Crash Recovery", "Preview Position"} {
if !strings.Contains(view, field) {
t.Errorf("View should contain field %q", field)
}
Expand Down Expand Up @@ -411,3 +444,47 @@ func TestThemeDisplay(t *testing.T) {
}
}
}

// ---------------------------------------------------------------------------
// Preview position helpers
// ---------------------------------------------------------------------------

func TestCyclePreviewPosition(t *testing.T) {
tests := []struct {
current string
want string
}{
{"right", "bottom"},
{"bottom", "left"},
{"left", "top"},
{"top", "right"},
{"", "bottom"}, // default/empty cycles to bottom
{"invalid", "bottom"}, // invalid cycles to bottom
}
for _, tt := range tests {
got := cyclePreviewPosition(tt.current)
if got != tt.want {
t.Errorf("cyclePreviewPosition(%q) = %q, want %q", tt.current, got, tt.want)
}
}
}

func TestPreviewPositionDisplay(t *testing.T) {
tests := []struct {
pos string
want string
}{
{"right", "Right"},
{"bottom", "Bottom"},
{"left", "Left"},
{"top", "Top"},
{"", "Right"}, // default shows Right
{"invalid", "Right"}, // invalid shows Right
}
for _, tt := range tests {
got := previewPositionDisplay(tt.pos)
if !strings.Contains(got, tt.want) {
t.Errorf("previewPositionDisplay(%q) = %q, want to contain %q", tt.pos, got, tt.want)
}
}
}
2 changes: 1 addition & 1 deletion internal/tui/components/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/jongio/dispatch/internal/tui/styles"
)

// PreviewPanel renders a right-side detail panel for the selected session.
// PreviewPanel renders a detail panel for the selected session.
type PreviewPanel struct {
detail *data.SessionDetail
attentionStatus data.AttentionStatus
Expand Down
Loading
Loading