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
119 changes: 119 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ type KeyMap struct {
// Jump to pinned/unpinned tasks
JumpToPinned key.Binding
JumpToUnpinned key.Binding
// Detail-view pinned quick-nav (hop between focused/pinned tasks)
PrevPinnedTask key.Binding
NextPinnedTask key.Binding
// Column collapse
CollapseBacklog key.Binding
CollapseDone key.Binding
Expand Down Expand Up @@ -253,6 +256,14 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("shift+down"),
key.WithHelp(IconShiftDown(), "jump to unpinned"),
),
PrevPinnedTask: key.NewBinding(
key.WithKeys("["),
key.WithHelp("[", "prev pinned"),
),
NextPinnedTask: key.NewBinding(
key.WithKeys("]"),
key.WithHelp("]", "next pinned"),
),
CollapseBacklog: key.NewBinding(
key.WithKeys("["),
key.WithHelp("[", "collapse backlog"),
Expand Down Expand Up @@ -964,6 +975,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

// Reapply filter if one is active
m.applyFilter()
// Keep the detail view's pinned quick-nav in sync with the new task set
// (e.g. after a pin/unpin, which reloads tasks).
m.refreshDetailPinnedNav()
m.kanban.SetHiddenDoneCount(msg.hiddenDoneCount)
// Refresh running process indicators for all tasks
running := executor.GetTasksWithRunningShellProcess()
Expand Down Expand Up @@ -1044,6 +1058,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Set task position in column for display
pos, total := m.kanban.GetTaskPosition()
m.detailView.SetPosition(pos, total)
// Populate the pinned quick-nav bar (respects the active board filter)
m.refreshDetailPinnedNav()
m.previousView = m.currentView
m.currentView = ViewDetail
// Start async pane setup if needed
Expand Down Expand Up @@ -2223,6 +2239,70 @@ func (m *AppModel) resolveProjectAliases(query string) string {
return result.String()
}

// filteredPinnedTasks returns the pinned tasks that pass the board's currently
// active filter, ordered by status column then ID for stability. This is the set
// the detail view's quick-nav bar hops between — so a filtered board ("[projectX]")
// only surfaces its own pinned tasks, not every pin across the board.
func (m *AppModel) filteredPinnedTasks() []*db.Task {
queryLower := strings.ToLower(m.filterText)
if strings.Contains(queryLower, "[") {
queryLower = m.resolveProjectAliases(queryLower)
}

var pinned []*db.Task
for _, task := range m.tasks {
if !task.Pinned {
continue
}
if queryLower != "" && scoreTaskForFilter(task, queryLower) < 0 {
continue
}
pinned = append(pinned, task)
}

sort.SliceStable(pinned, func(i, j int) bool {
ri, rj := statusColumnRank(pinned[i].Status), statusColumnRank(pinned[j].Status)
if ri != rj {
return ri < rj
}
return pinned[i].ID < pinned[j].ID
})
return pinned
}

// statusColumnRank orders task statuses left-to-right the way the board columns
// read, so the pinned nav bar lists tasks in a familiar order.
func statusColumnRank(status string) int {
switch status {
case db.StatusBacklog:
return 0
case db.StatusQueued:
return 1
case db.StatusProcessing:
return 2
case db.StatusBlocked:
return 3
case db.StatusDone:
return 4
default:
return 5
}
}

// refreshDetailPinnedNav rebuilds the detail view's pinned quick-nav bar from the
// current task set and filter. Safe to call when not in the detail view.
func (m *AppModel) refreshDetailPinnedNav() {
if m.detailView == nil || m.selectedTask == nil {
return
}
pinned := m.filteredPinnedTasks()
items := make([]PinnedNavItem, len(pinned))
for i, t := range pinned {
items[i] = PinnedNavItem{ID: t.ID, Title: t.Title, Status: t.Status, Project: t.Project}
}
m.detailView.SetPinnedNav(items, m.selectedTask.ID)
}

// applyFilter filters the tasks based on current filter text using fuzzy matching.
// Uses the same matching logic as the command palette (Ctrl+P) for consistency.
func (m *AppModel) applyFilter() {
Expand Down Expand Up @@ -2570,6 +2650,22 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

// Pinned quick-nav: hop between the pinned tasks shown in the detail view's
// top bar. [ / ] cycle prev/next; 1-9 jump to that pill directly.
if m.detailView != nil && m.detailView.HasPinnedNav() {
if key.Matches(keyMsg, m.keys.NextPinnedTask) {
return m.jumpToPinnedTask(m.detailView.PinnedNavNextID())
}
if key.Matches(keyMsg, m.keys.PrevPinnedTask) {
return m.jumpToPinnedTask(m.detailView.PinnedNavPrevID())
}
if s := keyMsg.String(); len(s) == 1 && s[0] >= '1' && s[0] <= '9' {
if id := m.detailView.PinnedNavIDAt(int(s[0] - '0')); id != 0 {
return m.jumpToPinnedTask(id)
}
}
}

if m.detailView != nil {
var cmd tea.Cmd
m.detailView, cmd = m.detailView.Update(msg)
Expand All @@ -2579,6 +2675,29 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

// jumpToPinnedTask switches the detail view to the given task, reusing the same
// cleanup/transition guard as prev/next navigation so we never fight over the
// executor pane. It selects the task in the kanban first so position display and
// subsequent arrow navigation stay consistent.
func (m *AppModel) jumpToPinnedTask(id int64) (tea.Model, tea.Cmd) {
if id == 0 {
return m, nil
}
if m.selectedTask != nil && m.selectedTask.ID == id {
return m, nil // already here
}
if m.taskTransitionInProgress {
return m, nil
}
m.taskTransitionInProgress = true
if m.detailView != nil {
m.detailView.CleanupWithoutSaving()
m.detailView = nil
}
m.kanban.SelectTask(id)
return m, m.loadTask(id)
}

func (m *AppModel) updateNewTaskForm(msg tea.Msg) (tea.Model, tea.Cmd) {
// Pass all messages to the form (form handles ESC with confirmation prompt)
model, cmd := m.newTaskForm.Update(msg)
Expand Down
64 changes: 50 additions & 14 deletions internal/ui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ type DetailModel struct {
positionInColumn int
totalInColumn int

// Pinned quick-nav: a compact pill bar at the top of the detail view for
// hopping between the tasks you're currently focused on (your pinned tasks,
// honouring the board's active filter). pinnedNavIndex is the position of the
// currently-viewed task within pinnedNav, or -1 when the current task isn't
// pinned. Populated by AppModel via SetPinnedNav whenever a task is opened or
// the task set changes.
pinnedNav []PinnedNavItem
pinnedNavIndex int

// Track joined tmux panes
claudePaneID string // The Claude Code pane (middle-left)
workdirPaneID string // The workdir shell pane (middle-right)
Expand Down Expand Up @@ -776,16 +785,22 @@ func (m *DetailModel) spinnerTick() tea.Cmd {
})
}

// headerHeight is the vertical space reserved for the box chrome around the
// viewport. The pinned quick-nav bar (rendered at the bottom of the box) adds
// one line and must be accounted for here or the viewport would overflow.
func (m *DetailModel) headerHeight() int {
h := 6
if m.showPinnedNav() {
h++
}
return h
}

func (m *DetailModel) initViewport() {
headerHeight := 6
footerHeight := 2

// If we have joined panes, we have less height (tmux split takes space)
vpHeight := m.height - headerHeight - footerHeight
if m.claudePaneID != "" || m.workdirPaneID != "" {
// The tmux split takes roughly half, but we don't control that here
// Just use full height - the TUI pane will be resized by tmux
}
vpHeight := m.height - m.headerHeight() - footerHeight

m.viewport = viewport.New(m.width-4, vpHeight)
m.setViewportContent()
Expand All @@ -796,13 +811,20 @@ func (m *DetailModel) initViewport() {
func (m *DetailModel) SetSize(width, height int) {
m.width = width
m.height = height
if m.ready {
headerHeight := 6
footerHeight := 2
m.viewport.Width = width - 4
m.viewport.Height = height - headerHeight - footerHeight
m.setViewportContent()
m.reflowViewport()
}

// reflowViewport recomputes the viewport dimensions from the current width,
// height, and header layout. Called on resize and whenever something that
// changes the header height (like the pinned nav bar appearing) is updated.
func (m *DetailModel) reflowViewport() {
if !m.ready {
return
}
footerHeight := 2
m.viewport.Width = m.width - 4
m.viewport.Height = m.height - m.headerHeight() - footerHeight
m.setViewportContent()
}

// Update handles messages.
Expand Down Expand Up @@ -2438,8 +2460,9 @@ func (m *DetailModel) View() string {

header := m.renderHeader()
help := m.renderHelp()
nav := m.renderPinnedNav()

sig := m.viewSignature(header, help)
sig := m.viewSignature(header, help, nav)
if m.cachedViewOK && m.cachedViewSig == sig {
return m.cachedView
}
Expand Down Expand Up @@ -2492,6 +2515,13 @@ func (m *DetailModel) View() string {
boxContent = lipgloss.JoinVertical(lipgloss.Left, header, content, scrollIndicator)
}

// Pinned quick-nav bar sits at the bottom of the box, just above the help
// footer, so the top stays clean. headerHeight() already reserves a line for
// it, so the viewport shrinks to make room.
if nav != "" {
boxContent = lipgloss.JoinVertical(lipgloss.Left, boxContent, nav)
}

renderedBox := box.Render(boxContent)

// When shell pane is hidden, show a collapsed indicator on the right
Expand Down Expand Up @@ -2536,7 +2566,7 @@ func (m *DetailModel) View() string {
// colours) without enumerating each one. The remaining inputs are the View-level
// state the header/help don't cover: the dangerous-mode banner, the bordered box,
// and the viewport's content/scroll geometry.
func (m *DetailModel) viewSignature(header, help string) uint64 {
func (m *DetailModel) viewSignature(header, help, nav string) uint64 {
h := newSigHasher()
h.u64(StyleGeneration()) // theme / project colour changes
h.int(m.width)
Expand All @@ -2555,6 +2585,7 @@ func (m *DetailModel) viewSignature(header, help string) uint64 {
h.int(m.viewport.VisibleLineCount())
h.str(header)
h.str(help)
h.str(nav) // pinned quick-nav bar (rendered at the box bottom)
return h.h
}

Expand Down Expand Up @@ -3115,6 +3146,11 @@ func (m *DetailModel) renderHelp() string {
{IconArrowUp() + "/" + IconArrowDown(), "prev/next task", !hasNavigation},
}

// Pinned quick-nav hint (only when the bar is shown)
if m.showPinnedNav() {
keys = append(keys, helpKey{"[/]", "pinned", false})
}

// Show scroll hint when content is scrollable
if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() {
keys = append(keys, helpKey{"j/k/wheel", "scroll", false})
Expand Down
Loading