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
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down
80 changes: 60 additions & 20 deletions internal/ui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
53 changes: 43 additions & 10 deletions internal/ui/detail_pinned_nav.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions internal/ui/detail_pinned_nav_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"
"testing"

"github.com/bborn/workflow/internal/config"
"github.com/bborn/workflow/internal/db"
)

Expand Down Expand Up @@ -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)
}
}
}
3 changes: 2 additions & 1 deletion parity-ignore.json
Original file line number Diff line number Diff line change
@@ -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."
}
Loading