diff --git a/internal/config/config.go b/internal/config/config.go index c8aefc0a..7231d167 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ const ( SettingDetailPaneHeight = "detail_pane_height" SettingShellPaneWidth = "shell_pane_width" SettingShellPaneHidden = "shell_pane_hidden" + SettingPinnedNavHidden = "pinned_nav_hidden" SettingIdleSuspendTimeout = "idle_suspend_timeout" SettingServerURL = "server_url" SettingWorktreeCleanupMaxAge = "worktree_cleanup_max_age" diff --git a/internal/ui/app.go b/internal/ui/app.go index b5419032..21007646 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -93,6 +93,8 @@ type KeyMap struct { // Detail-view pinned quick-nav (hop between focused/pinned tasks) PrevPinnedTask key.Binding NextPinnedTask key.Binding + // Detail-view: show/hide the pinned quick-nav row + TogglePinnedRow key.Binding // Column collapse CollapseBacklog key.Binding CollapseDone key.Binding @@ -264,6 +266,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("]"), key.WithHelp("]", "next pinned"), ), + TogglePinnedRow: key.NewBinding( + key.WithKeys("T"), + key.WithHelp("T", "show/hide pinned"), + ), CollapseBacklog: key.NewBinding( key.WithKeys("["), key.WithHelp("[", "collapse backlog"), @@ -2598,6 +2604,15 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailView.ToggleShellPane() return m, nil } + if key.Matches(keyMsg, m.keys.Help) && m.detailView != nil { + // Expand/collapse the detail footer help row. + m.detailView.ToggleHelpExpanded() + return m, nil + } + if key.Matches(keyMsg, m.keys.TogglePinnedRow) && m.detailView != nil { + m.detailView.TogglePinnedNav() + return m, nil + } // Arrow key navigation to prev/next task in the same column // j/k keys are passed through to the viewport for scrolling diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 56ea11ae..51213f44 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -123,6 +123,13 @@ type DetailModel struct { // the task set changes. pinnedNav []PinnedNavItem pinnedNavIndex int + // pinnedNavHidden lets the user collapse the pinned quick-nav row entirely + // (toggled with 'T'); persisted as a preference across tasks/sessions. + pinnedNavHidden bool + + // helpExpanded controls the footer help row: collapsed shows only the + // high-frequency actions plus a '?' affordance; expanded reveals the rest. + helpExpanded bool // Track joined tmux panes claudePaneID string // The Claude Code pane (middle-left) @@ -599,6 +606,11 @@ func NewDetailModel(t *db.Task, database *db.DB, exec *executor.Executor, width, m.shellPaneHidden = true } + // Load pinned quick-nav visibility preference from settings + if hiddenStr, err := database.GetSetting(config.SettingPinnedNavHidden); err == nil && hiddenStr == "true" { + m.pinnedNavHidden = true + } + // Load logs logs, _ := database.GetTaskLogs(t.ID, 100) m.logs = logs @@ -3137,50 +3149,62 @@ func (m *DetailModel) renderHelp() string { key string desc string disabled bool // When disabled, always show grayed out + primary bool // Shown even when the help row is collapsed } // Check if navigation is available (more than 1 task in column) hasNavigation := m.totalInColumn > 1 + // Primary keys are the handful of high-frequency actions kept visible when + // the row is collapsed; everything else is tucked behind '?'. keys := []helpKey{ - {IconArrowUp() + "/" + IconArrowDown(), "prev/next task", !hasNavigation}, + {IconArrowUp() + "/" + IconArrowDown(), "prev/next task", !hasNavigation, true}, } // Pinned quick-nav hint (only when the bar is shown) if m.showPinnedNav() { - keys = append(keys, helpKey{"[/]", "pinned", false}) + keys = append(keys, helpKey{"[/]", "pinned", false, true}) } // Show scroll hint when content is scrollable if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() { - keys = append(keys, helpKey{"j/k/wheel", "scroll", false}) + keys = append(keys, helpKey{"j/k/wheel", "scroll", false, false}) } // Only show execute/retry when Claude is not running claudeRunning := m.claudeMemoryMB > 0 if !claudeRunning { - keys = append(keys, helpKey{"x", "execute", false}) - keys = append(keys, helpKey{"X", "execute dangerous", false}) + keys = append(keys, helpKey{"x", "execute", false, true}) + keys = append(keys, helpKey{"X", "execute dangerous", false, false}) } hasPanes := m.claudePaneID != "" || m.workdirPaneID != "" - keys = append(keys, helpKey{"e", "edit", false}) + keys = append(keys, helpKey{"e", "edit", false, true}) // Only show retry when Claude is not running if !claudeRunning { - keys = append(keys, helpKey{"r", "retry", false}) + keys = append(keys, helpKey{"r", "retry", false, false}) } // Always show status change option - keys = append(keys, helpKey{"S", "status", false}) + keys = append(keys, helpKey{"S", "status", false, true}) if m.task != nil { pinDesc := "pin task" if m.task.Pinned { pinDesc = "unpin task" } - keys = append(keys, helpKey{"t", pinDesc, false}) + keys = append(keys, helpKey{"t", pinDesc, false, false}) + } + + // Toggle the pinned quick-nav row (only when there are pinned tasks to show). + if m.pinnedNavAvailable() { + toggleDesc := "hide pinned" + if m.pinnedNavHidden { + toggleDesc = "show pinned" + } + keys = append(keys, helpKey{"T", toggleDesc, false, false}) } // Show dangerous mode toggle when task is processing or blocked @@ -3189,23 +3213,23 @@ func (m *DetailModel) renderHelp() string { if m.task.DangerousMode { toggleDesc = "safe mode" } - keys = append(keys, helpKey{"!", toggleDesc, false}) + keys = append(keys, helpKey{"!", toggleDesc, false, false}) } // Show pane navigation shortcut when panes are visible if hasPanes && os.Getenv("TMUX") != "" { - keys = append(keys, helpKey{"shift+" + IconArrowUp() + IconArrowDown(), "switch pane", false}) + keys = append(keys, helpKey{"shift+" + IconArrowUp() + IconArrowDown(), "switch pane", false, false}) // Show shell pane toggle shortcut toggleDesc := "hide shell" if m.shellPaneHidden { toggleDesc = "show shell" } - keys = append(keys, helpKey{"\\", toggleDesc, false}) + keys = append(keys, helpKey{"\\", toggleDesc, false, false}) } // Open PR shortcut (only when task has a PR) if m.task != nil && m.task.PRURL != "" { - keys = append(keys, helpKey{"G", "open PR", false}) + keys = append(keys, helpKey{"G", "open PR", false, false}) } // Show contextual label for 'b' key based on whether process is running @@ -3214,19 +3238,24 @@ func (m *DetailModel) renderHelp() string { browserLabel = "browser" } keys = append(keys, []helpKey{ - {"b", browserLabel, false}, - {"c", "close", false}, - {"a", "archive", false}, - {"d", "delete", false}, - {"esc", "back", false}, + {"b", browserLabel, false, false}, + {"c", "close", false, false}, + {"a", "archive", false, false}, + {"d", "delete", false, false}, + {"esc", "back", false, true}, }...) var help string dimmedKeyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) dimmedDescStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4B5563")) - for i, k := range keys { - if i > 0 { + rendered := 0 + for _, k := range keys { + // When collapsed, only the primary keys are shown. + if !m.helpExpanded && !k.primary { + continue + } + if rendered > 0 { help += " " } // Disabled keys are always dimmed, regardless of focus @@ -3235,7 +3264,18 @@ func (m *DetailModel) renderHelp() string { } else { help += HelpKey.Render(k.key) + " " + HelpDesc.Render(k.desc) } + rendered++ + } + + // Trailing '?' affordance to expand/collapse the rest. + moreDesc := "more" + if m.helpExpanded { + moreDesc = "less" + } + if rendered > 0 { + help += " " } + help += dimmedKeyStyle.Render("?") + " " + dimmedDescStyle.Render(moreDesc) return HelpBar.Render(help) } diff --git a/internal/ui/detail_pinned_nav.go b/internal/ui/detail_pinned_nav.go index 483108ed..86400112 100644 --- a/internal/ui/detail_pinned_nav.go +++ b/internal/ui/detail_pinned_nav.go @@ -5,6 +5,8 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + + "github.com/bborn/workflow/internal/config" ) // PinnedNavItem is a single entry in the detail view's pinned quick-nav bar. @@ -40,10 +42,10 @@ func (m *DetailModel) HasPinnedNav() bool { return m.showPinnedNav() } -// showPinnedNav decides whether the bar is worth showing. We hide it when there -// is nowhere to hop to: no pinned tasks, or the only pinned task is the one -// already open. -func (m *DetailModel) showPinnedNav() bool { +// pinnedNavAvailable reports whether there are pinned tasks worth showing a bar +// for: at least one pinned task that isn't just the one already open. This +// ignores the user's hide preference, so the 'T' toggle is still offered. +func (m *DetailModel) pinnedNavAvailable() bool { n := len(m.pinnedNav) if n == 0 { return false @@ -54,6 +56,35 @@ func (m *DetailModel) showPinnedNav() bool { return true } +// showPinnedNav decides whether the bar is actually rendered: there must be +// somewhere to hop to, and the user must not have hidden it. +func (m *DetailModel) showPinnedNav() bool { + return !m.pinnedNavHidden && m.pinnedNavAvailable() +} + +// ToggleHelpExpanded flips the footer help row between the collapsed (primary +// actions + '?') and expanded (all actions) states. +func (m *DetailModel) ToggleHelpExpanded() { + m.helpExpanded = !m.helpExpanded + m.cachedViewOK = false +} + +// TogglePinnedNav shows or hides the pinned quick-nav row and persists the +// choice. Hiding/showing changes the reserved chrome height, so the viewport is +// reflowed to match. +func (m *DetailModel) TogglePinnedNav() { + m.pinnedNavHidden = !m.pinnedNavHidden + hidden := "false" + if m.pinnedNavHidden { + hidden = "true" + } + if m.database != nil { + m.database.SetSetting(config.SettingPinnedNavHidden, hidden) + } + m.reflowViewport() + m.cachedViewOK = false +} + // PinnedNavNextID returns the ID of the pinned task after the current one // (wrapping around), or 0 if navigation isn't possible. func (m *DetailModel) PinnedNavNextID() int64 { @@ -101,16 +132,18 @@ func (m *DetailModel) renderPinnedNav() string { label := lipgloss.NewStyle().Foreground(ColorMuted).Render(IconPin() + " ") + // The current pill used to be a saturated blue block, which read as too loud + // on an otherwise calm screen. Tone it down: a subtle neutral chip with a + // gentle accented (bold, primary-coloured) label marks "you are here", while + // the other pins are quiet unfilled text that recede. currentStyle := lipgloss.NewStyle(). - Background(ColorPrimary). - Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#2C313A")). + Foreground(ColorPrimary). Bold(true) restStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#374151")). - Foreground(lipgloss.Color("#D1D5DB")) + Foreground(lipgloss.Color("#828997")) dimStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#374151")). - Foreground(lipgloss.Color("#6B7280")) + Foreground(ColorMuted) moreStyle := lipgloss.NewStyle().Foreground(ColorMuted) n := len(m.pinnedNav) diff --git a/internal/ui/detail_pinned_nav_test.go b/internal/ui/detail_pinned_nav_test.go index af811f49..6ed0ea36 100644 --- a/internal/ui/detail_pinned_nav_test.go +++ b/internal/ui/detail_pinned_nav_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/bborn/workflow/internal/config" "github.com/bborn/workflow/internal/db" ) @@ -129,3 +130,69 @@ func TestPinnedNavRendersInView(t *testing.T) { t.Errorf("headerHeight with nav = %d, want 7", m.headerHeight()) } } + +func TestTogglePinnedNavHidesAndPersists(t *testing.T) { + m := newNavDetailModel(t) + m.SetPinnedNav([]PinnedNavItem{{ID: 1, Title: "Current"}, {ID: 2, Title: "Other"}}, 1) + + if !m.showPinnedNav() { + t.Fatal("precondition: bar should be visible") + } + + m.TogglePinnedNav() + if m.showPinnedNav() { + t.Error("after toggle, the bar should be hidden") + } + // It's only hidden by preference — the content is still available, so the + // 'T' toggle remains offered. + if !m.pinnedNavAvailable() { + t.Error("pinnedNavAvailable should stay true while merely hidden") + } + // Hidden means it no longer reserves a chrome line. + if m.headerHeight() != 6 { + t.Errorf("headerHeight while hidden = %d, want 6", m.headerHeight()) + } + // Preference persisted. + if v, _ := m.database.GetSetting(config.SettingPinnedNavHidden); v != "true" { + t.Errorf("expected persisted pinned_nav_hidden=true, got %q", v) + } + + m.TogglePinnedNav() + if !m.showPinnedNav() { + t.Error("second toggle should show the bar again") + } + if v, _ := m.database.GetSetting(config.SettingPinnedNavHidden); v != "false" { + t.Errorf("expected persisted pinned_nav_hidden=false, got %q", v) + } +} + +func TestHelpRowCollapsesBehindQuestionMark(t *testing.T) { + m := newNavDetailModel(t) + m.SetPinnedNav([]PinnedNavItem{{ID: 1, Title: "Current"}, {ID: 2, Title: "Other"}}, 1) + m.totalInColumn = 3 // enable prev/next hint + + // Collapsed (default): primary actions + a "? more" affordance, but not the + // secondary ones like archive/delete or the pinned-row toggle. + collapsed := m.renderHelp() + if !strings.Contains(collapsed, "more") { + t.Errorf("collapsed help should advertise '? more', got:\n%s", collapsed) + } + for _, hidden := range []string{"archive", "delete", "hide pinned"} { + if strings.Contains(collapsed, hidden) { + t.Errorf("collapsed help should hide %q, got:\n%s", hidden, collapsed) + } + } + if !strings.Contains(collapsed, "edit") { + t.Errorf("collapsed help should keep primary action 'edit', got:\n%s", collapsed) + } + + // Expanded: the secondary actions and the pinned-row toggle appear, and the + // affordance flips to "? less". + m.ToggleHelpExpanded() + expanded := m.renderHelp() + for _, shown := range []string{"archive", "delete", "hide pinned", "less"} { + if !strings.Contains(expanded, shown) { + t.Errorf("expanded help should include %q, got:\n%s", shown, expanded) + } + } +} diff --git a/parity-ignore.json b/parity-ignore.json index de8be5a4..4be74606 100644 --- a/parity-ignore.json +++ b/parity-ignore.json @@ -1,4 +1,5 @@ { "PrevPinnedTask": "TUI-only keyboard affordance ('[') for cycling to the previous pinned task from the detail view's quick-nav bar; the desktop GUI switches tasks via its own list/click UI and honors the same board filter, so no bracket-key binding is needed there.", - "NextPinnedTask": "TUI-only keyboard affordance (']') for cycling to the next pinned task from the detail view's quick-nav bar; the desktop GUI switches tasks via its own list/click UI and honors the same board filter, so no bracket-key binding is needed there." + "NextPinnedTask": "TUI-only keyboard affordance (']') for cycling to the next pinned task from the detail view's quick-nav bar; the desktop GUI switches tasks via its own list/click UI and honors the same board filter, so no bracket-key binding is needed there.", + "TogglePinnedRow": "TUI-only affordance ('T') to show/hide the detail view's pinned quick-nav row; the desktop GUI has no fixed pill row to collapse, so there's nothing to mirror." }