How to Browse and Filter the Pipeline in the Go Bubble Tea Dashboard TUI

The career-ops TUI implements browsing and filtering through a central PipelineModel that maintains a full dataset slice and a derived filtered slice, applying tab-based filters, live search, and multi-column sorts via the applyFilterAndSort method.

The santifer/career-ops repository provides a terminal-based dashboard for managing job applications. The pipeline view is a sophisticated Go Bubble Tea model that combines keyboard-driven navigation with real-time filtering and sorting capabilities. Understanding its internal mechanics reveals how to efficiently manage large application lists without leaving the terminal.

Core Data Structures

The pipeline’s state management centers on a few key structures defined in dashboard/internal/ui/screens/pipeline.go.

PipelineModel (lines 59‑94) serves as the primary Bubble Tea model. It stores the complete dataset in apps (a slice of model.CareerApplication) and maintains the currently visible subset in filtered. The model also tracks UI state including cursor position, scroll offset, active tab index, current sort mode, and the live search query string.

pipelineTab (lines 41‑44) pairs a filter key—such as filterAll, filterEvaluated, or filterApplied—with its display label. These tabs drive the primary filtering interface when users press f, h, or the arrow keys.

colDef (lines 76‑84) describes optional columns that users can toggle via the C key, while reportSummary (lines 8‑13) caches parsed report data for the preview pane.

Filtering Logic

All filtering happens inside applyFilterAndSort (lines 1138‑1160), which rebuilds the filtered slice whenever the user changes tabs or modifies the search query.

Tab-Based Filtering

Each tab maps to a specific filter constant. When applyFilterAndSort runs, it retrieves the current filter from getPipelineTabs()[m.activeTab].filter and iterates through m.apps:

func (m *PipelineModel) applyFilterAndSort() {
    var filtered []model.CareerApplication
    currentFilter := getPipelineTabs()[m.activeTab].filter

    for _, app := range m.apps {
        if !matchesSearch(app, m.searchQuery) { continue }
        norm := data.NormalizeStatus(app.Status)
        switch currentFilter {
        case filterAll:
            filtered = append(filtered, app)
        case filterTop:
            if app.Score >= 4.0 && norm != "skip" { filtered = append(filtered, app) }
        default:
            if norm == currentFilter { filtered = append(filtered, app) }
        }
    }
    // Sorting logic follows...
    m.filtered = filtered
}

The data.NormalizeStatus function standardizes status strings before comparison, ensuring that variations like "Interview Scheduled" and "interview" match consistently.

Pressing / activates the search bar. As the user types, handleSearchInput (lines 33‑85) updates m.searchQuery, and matchesSearch (lines 21‑35) performs a case‑insensitive substring match across company name, role title, and notes fields. An empty query matches all records, allowing seamless toggling between filtered and unfiltered views.

Sorting Implementation

The model supports seven sort modes cycled with the s key:

var sortCycle = []string{sortScore, sortDate, sortCompany,
    sortStatus, sortLocation, sortPay, sortLast}

sortLess (lines 86‑115) returns a comparator function based on the active mode:

func (m PipelineModel) sortLess() func(a, b model.CareerApplication) bool {
    switch m.sortMode {
    case sortDate:      return func(a, b model.CareerApplication) bool { return a.Date > b.Date }
    case sortCompany:   return func(a, b model.CareerApplication) bool { return strings.ToLower(a.Company) < strings.ToLower(b.Company) }
    case sortStatus:    return func(a, b model.CareerApplication) bool { return data.StatusPriority(a.Status) < data.StatusPriority(b.Status) }
    case sortLocation:  return func(a, b model.CareerApplication) bool { … }
    case sortPay:       return func(a, b model.CareerApplication) bool { return a.PayMax > b.PayMax }
    case sortLast:      return func(a, b model.CareerApplication) bool { return a.LastContact > b.LastContact }
    default:            return func(a, b model.CareerApplication) bool { return a.Score > b.Score }
    }
}

When the view mode is set to grouped, the function first applies the selected comparator, then performs a stable sort by data.StatusPriority (lines 68‑78) to keep status blocks visually separated while maintaining internal order within each group.

handleKey (lines 15‑70) processes all keyboard input for cursor movement:

  • j / down: Move cursor down one row
  • k / up: Move cursor up one row
  • PageDown / ctrl+d: Jump half a page down
  • PageUp / ctrl+u: Jump half a page up
  • g: Jump to first row
  • G: Jump to last row

After each movement, adjustScroll (lines 51‑69) recalculates the visible window based on terminal height, fixed header/footer rows, and the estimated preview height, ensuring the cursor remains visible. The method then calls loadCurrentReport (lines 103‑115) to lazy‑load the report preview for the newly selected application.

Advanced Features

Column Picker

Press C to open the column picker overlay. The handleColPicker function (lines 71‑88) iterates through getOptionalCols() and toggles visibility flags in m.visibleCols. These booleans directly influence columnWidths (lines 54‑79), which dynamically adjusts the rendered table layout.

Status and Discard Flows

Pressing c opens the status picker via handleStatusPicker (lines 89‑117). Selecting Discarded or Skip triggers startDiscardFlow, which loads predicted reasons from the report using data.LoadReportDiscardReasons, merges them with canonicalDiscardReasons, and displays a secondary picker. Confirming a reason emits a PipelineUpdateStatusAndNotesMsg that atomically updates the status and appends a DISCARD: or SKIP: tag to the tracker notes.

Report Preview and PDF Actions

When the cursor lands on an entry with a ReportPath, loadCurrentReport emits a PipelineLoadReportMsg. Press Enter to open the report viewer, d to open the newest generated PDF, and D to regenerate the PDF from source HTML via PipelineGeneratePDFMsg.

Summary

  • The Go Bubble Tea dashboard TUI stores all applications in PipelineModel.apps and renders a derived filtered slice.
  • Filtering combines tab-based category filters with real-time substring search via applyFilterAndSort.
  • Sorting supports seven modes (score, date, company, status, location, pay, last contact) and can group results by status priority.
  • Navigation uses vim-style keybindings processed by handleKey, with automatic scroll adjustment through adjustScroll.
  • Column visibility, status updates, and PDF generation are handled through dedicated picker overlays and command messages.

Frequently Asked Questions

How do I filter applications by status in the career-ops TUI?

Press f to cycle forward or h to cycle backward through the tab bar. Each tab maps to a filter constant (filterEvaluated, filterApplied, etc.) that applyFilterAndSort uses to include only matching applications in the filtered slice. You can also press / to activate live search and filter by company name, role, or notes.

What keyboard shortcuts move the cursor in the pipeline view?

Use j or down arrow to move down one row, k or up arrow to move up, PageDown/ctrl+d for half-page jumps, and g or G to jump to the first or last row respectively. These bindings are handled in handleKey (lines 38‑70), which automatically adjusts the scroll viewport to keep your selection visible.

How does the TUI handle sorting when I press the 's' key?

Pressing s cycles through sortCycle, updating m.sortMode. The model then calls applyFilterAndSort, which retrieves a comparator from sortLess (lines 86‑115). In grouped view mode, the function performs a stable secondary sort by data.StatusPriority to keep applications clustered by status regardless of the primary sort key.

Can I customize which columns appear in the application table?

Yes. Press C to open the column picker managed by handleColPicker (lines 71‑88). Navigate with j/k and toggle columns with Space. Your selections are stored in m.visibleCols and consulted by columnWidths (lines 54‑79) to render the table with only your chosen data fields.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →