diff --git a/.beans/beans-3f64--phase-1-compact-list-format.md b/.beans/beans-3f64--phase-1-compact-list-format.md new file mode 100644 index 00000000..93ea0f32 --- /dev/null +++ b/.beans/beans-3f64--phase-1-compact-list-format.md @@ -0,0 +1,12 @@ +--- +# beans-3f64 +title: 'Phase 1: Compact list format' +status: completed +type: task +priority: normal +created_at: 2025-12-28T17:38:41Z +updated_at: 2025-12-28T18:44:44Z +parent: beans-t0tv +--- + +Add single-character type and status codes to make the list more compact. Prerequisite for two-column layout. \ No newline at end of file diff --git a/.beans/beans-41ly--phase-3-two-column-layout-composition.md b/.beans/beans-41ly--phase-3-two-column-layout-composition.md new file mode 100644 index 00000000..338cbc58 --- /dev/null +++ b/.beans/beans-41ly--phase-3-two-column-layout-composition.md @@ -0,0 +1,12 @@ +--- +# beans-41ly +title: 'Phase 3: Two-column layout composition' +status: completed +type: task +priority: normal +created_at: 2025-12-28T17:38:52Z +updated_at: 2025-12-28T18:56:24Z +parent: beans-t0tv +--- + +Wire up the two-column layout in the main App with responsive width detection. \ No newline at end of file diff --git a/.beans/beans-433o--phase-2-detail-preview-component.md b/.beans/beans-433o--phase-2-detail-preview-component.md new file mode 100644 index 00000000..09a619a4 --- /dev/null +++ b/.beans/beans-433o--phase-2-detail-preview-component.md @@ -0,0 +1,12 @@ +--- +# beans-433o +title: 'Phase 2: Detail preview component' +status: completed +type: task +priority: normal +created_at: 2025-12-28T17:38:51Z +updated_at: 2025-12-28T18:49:10Z +parent: beans-t0tv +--- + +Create a lightweight, read-only detail preview that can be rendered in the right pane. \ No newline at end of file diff --git a/.beans/beans-6x50--phase-5-integration-and-polish.md b/.beans/beans-6x50--phase-5-integration-and-polish.md new file mode 100644 index 00000000..6c834671 --- /dev/null +++ b/.beans/beans-6x50--phase-5-integration-and-polish.md @@ -0,0 +1,12 @@ +--- +# beans-6x50 +title: 'Phase 5: Integration and polish' +status: completed +type: task +priority: normal +created_at: 2025-12-28T17:38:53Z +updated_at: 2025-12-28T19:04:41Z +parent: beans-t0tv +--- + +Final integration, help overlay updates, edge case handling, and testing. \ No newline at end of file diff --git a/.beans/beans-ld8p--two-column-layout-right-pane-extends-beyond-screen.md b/.beans/beans-ld8p--two-column-layout-right-pane-extends-beyond-screen.md new file mode 100644 index 00000000..b496585d --- /dev/null +++ b/.beans/beans-ld8p--two-column-layout-right-pane-extends-beyond-screen.md @@ -0,0 +1,12 @@ +--- +# beans-ld8p +title: 'Two-column layout: right pane extends beyond screen width' +status: scrapped +type: bug +priority: normal +created_at: 2025-12-28T19:20:10Z +updated_at: 2025-12-28T19:22:00Z +parent: t0tv +--- + +Same root cause as beans-m3mq - the footer in list.View() is not width-constrained, causing lipgloss.JoinHorizontal to miscalculate widths. See beans-m3mq for details. \ No newline at end of file diff --git a/.beans/beans-m3mq--two-column-layout-left-pane-too-narrow-with-whites.md b/.beans/beans-m3mq--two-column-layout-left-pane-too-narrow-with-whites.md new file mode 100644 index 00000000..1ac1a771 --- /dev/null +++ b/.beans/beans-m3mq--two-column-layout-left-pane-too-narrow-with-whites.md @@ -0,0 +1,34 @@ +--- +# beans-m3mq +title: 'Two-column layout: left pane too narrow with whitespace gap' +status: completed +type: bug +priority: normal +created_at: 2025-12-28T19:20:10Z +updated_at: 2025-12-28T19:49:19Z +parent: beans-t0tv +--- + +The left pane only takes up ~40 chars instead of the intended 55 chars. There's significant whitespace between the left and right panes. + +## Screenshot + +``` +╭─────────────────────────────────────────────────────╮ ╭───────────────────────────── +│ Beans │ │ beans-18db +│ │ │ beans milestones command +│ beans-f11p M I Milestone 0.4.0 │ │ +│ ├─ beans-hz87 F T Add blocked-by relatio... │ │ Status: todo Type: task +``` + +## Root Cause (investigation notes) + +The issue is in `list.View()` (list.go:500-567): +- The border box is constrained to `m.width - 2` +- BUT the footer is appended as `content + "\n" + footer` without width constraint +- The footer line extends to full terminal width +- `lipgloss.JoinHorizontal` sees the left pane width as the footer width (unbounded), not the box width + +## Fix + +The footer needs to be constrained to the same width as the border box, or the entire View() output needs width clamping. \ No newline at end of file diff --git a/.beans/beans-pri5--phase-4-cursor-sync.md b/.beans/beans-pri5--phase-4-cursor-sync.md new file mode 100644 index 00000000..cf481a42 --- /dev/null +++ b/.beans/beans-pri5--phase-4-cursor-sync.md @@ -0,0 +1,12 @@ +--- +# beans-pri5 +title: 'Phase 4: Cursor sync' +status: completed +type: task +priority: normal +created_at: 2025-12-28T17:38:53Z +updated_at: 2025-12-28T18:59:15Z +parent: beans-t0tv +--- + +Detect cursor changes in the list and update the preview pane accordingly. \ No newline at end of file diff --git a/.beans/beans-t0tv--refactor-tui-to-two-column-layout-with-hierarchica.md b/.beans/beans-t0tv--refactor-tui-to-two-column-layout-with-hierarchica.md index 3bfff83d..93bb73c6 100644 --- a/.beans/beans-t0tv--refactor-tui-to-two-column-layout-with-hierarchica.md +++ b/.beans/beans-t0tv--refactor-tui-to-two-column-layout-with-hierarchica.md @@ -1,10 +1,11 @@ --- # beans-t0tv title: Refactor TUI to two-column layout with hierarchical navigation -status: todo +status: in-progress type: feature +priority: normal created_at: 2025-12-14T15:37:22Z -updated_at: 2025-12-14T15:37:22Z +updated_at: 2025-12-28T19:20:20Z parent: beans-f11p --- @@ -45,18 +46,109 @@ The current single-list view doesn't provide enough context about individual bea - Batch selection and editing - Opening bean in editor -## Checklist - -- [ ] Design the two-column layout structure with Bubbletea -- [ ] Implement left pane (bean list) component -- [ ] Implement right pane (bean detail) component -- [ ] Add responsive width handling between panes -- [ ] Implement Enter to drill into bean hierarchy -- [ ] Implement back navigation (Escape/Backspace) -- [ ] Add breadcrumb/path indicator showing current root -- [ ] Preserve filtering functionality -- [ ] Preserve status change shortcuts -- [ ] Preserve batch selection and editing -- [ ] Preserve editor integration -- [ ] Handle edge cases (no children, deep nesting, narrow terminals) -- [ ] Update help overlay with new keybindings \ No newline at end of file +## Subtasks + +- beans-3f64: Phase 1 - Compact list format (single-char type/status) +- beans-433o: Phase 2 - Detail preview component +- beans-41ly: Phase 3 - Two-column layout composition +- beans-pri5: Phase 4 - Cursor sync +- beans-6x50: Phase 5 - Integration and polish + +## Implementation Plan + +See `_spec/plans/2025-12-28-tui-two-column-layout.md` for detailed implementation steps. + +## Research + +- [[2025-12-28-beans-t0tv-tui-two-column-layout]] - Codebase research documenting the current TUI implementation and architecture considerations for two-column layout + +## Design + +### Key Decisions + +1. **No hierarchy drilling** - list stays flat with tree structure, filtering handles focus +2. **Cursor updates preview** - moving through list immediately shows bean details +3. **Read-only right pane** - no focus, no shortcuts, just visual preview +4. **Enter for full detail** - opens existing full-screen detail view with all features +5. **Responsive collapse** - below 120 columns, single-column list (current behavior) +6. **Compact list format** - single-character type/status codes everywhere + +### Layout + +**Two-column mode (≥120 columns):** +``` +┌─────────────────────────────────┬──────────────────────────────────────────┐ +│ Beans │ beans-t0tv │ +│ │ Refactor TUI to two-column layout │ +│ ▌ beans-t0tv F T Refactor TUI │──────────────────────────────────────────│ +│ beans-f11p E T TUI Improve.. │ Status: todo Type: feature │ +│ beans-govy F T Add Y shortc. │ Parent: beans-f11p │ +│ │──────────────────────────────────────────│ +│ │ ## Summary │ +│ │ Refactor the TUI to a two-column format │ +│ │ ... │ +├─────────────────────────────────┴──────────────────────────────────────────┤ +│ enter view · e edit · space select · ? help │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +**Single-column mode (<120 columns):** Current list behavior, unchanged. + +**Dimensions:** +- Left pane: fixed 55 characters +- Right pane: remaining width minus borders +- Threshold: 120 columns for two-column mode + +### Compact List Format + +Single-character codes for type and status columns: + +**Types:** M(ilestone), E(pic), B(ug), F(eature), T(ask) + +**Statuses:** D(raft), T(odo), I(n-progress), C(ompleted), S(crapped) + +Applied everywhere (not just two-column mode) for consistency. + +### Navigation + +**In two-column mode:** +- `j/k`, arrows - move cursor, preview updates automatically +- `enter` - open full-screen detail view +- `space` - toggle multi-select +- `p/s/t/P/b/e/y/c` - existing shortcuts work on highlighted bean +- `g t` - tag filter, `/` - text filter, `?` - help overlay +- `esc` - clear selection, then clear filter + +**In full-screen detail (unchanged):** +- `tab` - switch focus between links and body +- `j/k` - scroll body +- `enter` - navigate to linked bean +- `esc` - back to two-column view + +### Implementation + +**Cursor sync:** Detect cursor change in list Update(), emit `cursorChangedMsg`. App handles it to update detail preview. + +**View rendering:** In `View()`, if width ≥120, compose left (list) + right (preview) with `lipgloss.JoinHorizontal`. + +**Files to modify:** +- `internal/tui/tui.go` - View() composition, cursor change handling +- `internal/tui/list.go` - ViewCompact(), compact type/status, cursor change detection +- `internal/tui/detail.go` - extract preview rendering +- `internal/ui/styles.go` - single-char type/status formatting helpers + +### Edge Cases + +- **Empty list:** right pane shows "No bean selected" +- **Terminal resize:** automatic switch between one/two column +- **Long body:** truncated in preview, scroll in full-screen detail +- **Bean deleted:** list reloads, cursor adjusts, preview updates +- **Multi-select:** preview shows cursor's bean (not summary) +- **Links in preview:** shown but non-interactive + +### Out of Scope (YAGNI) + +- Hierarchy drilling (Enter to show only children) +- Configurable pane widths +- Keyboard focus on right pane +- Breadcrumb navigation \ No newline at end of file diff --git a/.beans/beans-tbtr--two-column-layout-polish-footer-pane-widths-previe.md b/.beans/beans-tbtr--two-column-layout-polish-footer-pane-widths-previe.md new file mode 100644 index 00000000..af8ac0f3 --- /dev/null +++ b/.beans/beans-tbtr--two-column-layout-polish-footer-pane-widths-previe.md @@ -0,0 +1,16 @@ +--- +# beans-tbtr +title: 'Two-column layout polish: footer, pane widths, preview height' +status: completed +type: task +created_at: 2025-12-28T19:44:32Z +updated_at: 2025-12-28T19:44:32Z +parent: t0tv +--- + +Several polish fixes for the two-column TUI layout: + +- Footer is now app-global, spanning full terminal width (not constrained to left pane) +- Right pane capped at 80 chars max width (text files follow 80-char convention), left pane gets remaining space +- Preview height properly constrained to prevent overflow when bean body is long +- Detail view linked beans show full type/status names instead of single-char abbreviations \ No newline at end of file diff --git a/.beans/beans-vn93--responsive-typestatus-column-expansion-in-tui.md b/.beans/beans-vn93--responsive-typestatus-column-expansion-in-tui.md new file mode 100644 index 00000000..2c18ae72 --- /dev/null +++ b/.beans/beans-vn93--responsive-typestatus-column-expansion-in-tui.md @@ -0,0 +1,24 @@ +--- +# beans-vn93 +title: Responsive type/status column expansion in TUI +status: completed +type: task +priority: normal +created_at: 2025-12-29T18:30:52Z +updated_at: 2025-12-29T18:39:15Z +parent: beans-t0tv +--- + +Show full type/status names (e.g., 'feature', 'in-progress') when terminal is wide enough (≥120 cols), single-letter abbreviations (F, I) when space is tight. + +**Scope:** TUI only (not CLI output). + +## Plan + +1. Add `UseFullTypeStatus bool` to `ResponsiveColumns` struct +2. Update `CalculateResponsiveColumns()` to set flag when width ≥ 120 +3. Update list delegate to pass `UseFullNames: d.cols.UseFullTypeStatus` to `RenderBeanRow` + +## Files +- `internal/ui/styles.go` - ResponsiveColumns, CalculateResponsiveColumns +- `internal/tui/list.go` - itemDelegate.Render \ No newline at end of file diff --git a/_spec/plans/2025-12-28-tui-two-column-layout-design.md b/_spec/plans/2025-12-28-tui-two-column-layout-design.md new file mode 100644 index 00000000..f79de623 --- /dev/null +++ b/_spec/plans/2025-12-28-tui-two-column-layout-design.md @@ -0,0 +1,143 @@ +--- +date: 2025-12-28 +status: approved +bean: beans-t0tv +--- + +# TUI Two-Column Layout Design + +## Overview + +Add a two-column layout to the TUI: bean list on the left, read-only detail preview on the right. Cursor movement updates the preview. Enter opens full-screen detail view for interaction. + +## Key Decisions + +1. **No hierarchy drilling** - list stays flat with tree structure, filtering handles focus +2. **Cursor updates preview** - moving through list immediately shows bean details +3. **Read-only right pane** - no focus, no shortcuts, just visual preview +4. **Enter for full detail** - opens existing full-screen detail view with all features +5. **Responsive collapse** - below 120 columns, single-column list (current behavior) +6. **Compact list format** - single-character type/status codes everywhere + +## Layout + +**Two-column mode (≥120 columns):** +``` +┌─────────────────────────────────┬──────────────────────────────────────────┐ +│ Beans │ beans-t0tv │ +│ │ Refactor TUI to two-column layout │ +│ ▌ beans-t0tv F T Refactor TUI │──────────────────────────────────────────│ +│ beans-f11p E T TUI Improve.. │ Status: todo Type: feature │ +│ beans-govy F T Add Y shortc. │ Parent: beans-f11p │ +│ │──────────────────────────────────────────│ +│ │ ## Summary │ +│ │ Refactor the TUI to a two-column format │ +│ │ ... │ +├─────────────────────────────────┴──────────────────────────────────────────┤ +│ enter view · e edit · space select · ? help │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +**Single-column mode (<120 columns):** Current list behavior, unchanged. + +**Dimensions:** +- Left pane: fixed 55 characters +- Right pane: remaining width minus borders +- Threshold: 120 columns for two-column mode + +## Compact List Format + +Single-character codes for type and status columns: + +**Types:** +- M = milestone +- E = epic +- B = bug +- F = feature +- T = task + +**Statuses:** +- D = draft +- T = todo +- I = in-progress +- C = completed +- S = scrapped + +Applied everywhere (not just two-column mode) for consistency. + +## Navigation + +**In two-column mode:** +- `j/k`, arrows - move cursor, preview updates automatically +- `enter` - open full-screen detail view +- `space` - toggle multi-select +- `p/s/t/P/b/e/y/c` - existing shortcuts work on highlighted bean +- `g t` - tag filter +- `/` - text filter +- `?` - help overlay +- `esc` - clear selection, then clear filter + +**In full-screen detail (unchanged):** +- `tab` - switch focus between links and body +- `j/k` - scroll body +- `enter` - navigate to linked bean +- All existing shortcuts +- `esc` - back to two-column view + +## Implementation + +### State Changes + +No new fields in `App` struct. Reuse existing: +- `list` (listModel) for left pane +- Create lightweight detail preview from highlighted bean +- `width/height` for responsive behavior + +### Cursor Sync + +Detect cursor change in list Update(): +```go +previousIndex := m.list.Index() +m.list, cmd = m.list.Update(msg) +if m.list.Index() != previousIndex { + return m, tea.Batch(cmd, cursorChangedMsg{beanID: item.bean.ID}) +} +``` + +App handles `cursorChangedMsg` to update detail preview. + +### View Rendering + +```go +func (a *App) View() string { + if a.state == viewList && a.width >= 120 { + left := a.list.ViewCompact(55) + right := a.renderDetailPreview(a.width - 55 - 3) + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) + } + // Existing behavior for other cases +} +``` + +### Files to Modify + +- `internal/tui/tui.go` - View() composition, cursor change handling +- `internal/tui/list.go` - ViewCompact(), compact type/status rendering, cursor change detection +- `internal/tui/detail.go` - extract preview rendering (or create new lightweight preview) +- `internal/ui/styles.go` - single-char type/status formatting helpers + +## Edge Cases + +- **Empty list:** right pane shows "No bean selected" +- **Terminal resize:** automatic switch between one/two column +- **Long body:** truncated in preview, scroll in full-screen detail +- **Bean deleted:** list reloads, cursor adjusts, preview updates +- **Multi-select:** preview shows cursor's bean (not summary) +- **Links in preview:** shown but non-interactive + +## Out of Scope (YAGNI) + +- Hierarchy drilling (Enter to show only children) +- Configurable pane widths +- Keyboard focus on right pane +- Breadcrumb navigation diff --git a/_spec/plans/2025-12-28-tui-two-column-layout.md b/_spec/plans/2025-12-28-tui-two-column-layout.md new file mode 100644 index 00000000..d24f6c38 --- /dev/null +++ b/_spec/plans/2025-12-28-tui-two-column-layout.md @@ -0,0 +1,893 @@ +# TUI Two-Column Layout Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a two-column TUI layout with bean list on the left and read-only detail preview on the right. + +**Architecture:** Extend the existing Bubbletea TUI with responsive layout detection, compact list rendering, and a lightweight detail preview component. Cursor movement in the list automatically updates the preview. Enter opens the existing full-screen detail view. + +**Tech Stack:** Go, Bubbletea, Lipgloss, existing internal/tui and internal/ui packages. + +**Parent Bean:** beans-t0tv + +--- + +## Phase 1: Compact List Format + +**Bean:** beans-t0tv-p1 + +Add single-character type and status codes to make the list more compact. This is a prerequisite for the two-column layout where horizontal space is limited. + +### Task 1.1: Add Single-Char Type/Status Helpers + +**Files:** +- Modify: `internal/ui/styles.go` +- Test: `internal/ui/styles_test.go` + +**Step 1: Write the failing tests** + +Add to `internal/ui/styles_test.go`: + +```go +func TestShortType(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"milestone", "M"}, + {"epic", "E"}, + {"bug", "B"}, + {"feature", "F"}, + {"task", "T"}, + {"unknown", "?"}, + {"", "?"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := ShortType(tt.input) + if result != tt.expected { + t.Errorf("ShortType(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestShortStatus(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"draft", "D"}, + {"todo", "T"}, + {"in-progress", "I"}, + {"completed", "C"}, + {"scrapped", "S"}, + {"unknown", "?"}, + {"", "?"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := ShortStatus(tt.input) + if result != tt.expected { + t.Errorf("ShortStatus(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/ui/ -run "TestShort" -v` +Expected: FAIL with "undefined: ShortType" and "undefined: ShortStatus" + +**Step 3: Implement the helpers** + +Add to `internal/ui/styles.go`: + +```go +// ShortType returns a single-character code for the bean type. +func ShortType(t string) string { + switch t { + case "milestone": + return "M" + case "epic": + return "E" + case "bug": + return "B" + case "feature": + return "F" + case "task": + return "T" + default: + return "?" + } +} + +// ShortStatus returns a single-character code for the bean status. +func ShortStatus(s string) string { + switch s { + case "draft": + return "D" + case "todo": + return "T" + case "in-progress": + return "I" + case "completed": + return "C" + case "scrapped": + return "S" + default: + return "?" + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/ui/ -run "TestShort" -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/ui/styles.go internal/ui/styles_test.go +git commit -m "feat(ui): add ShortType and ShortStatus helpers + +Single-character codes for compact list display: +- Types: M(ilestone), E(pic), B(ug), F(eature), T(ask) +- Statuses: D(raft), T(odo), I(n-progress), C(ompleted), S(crapped) + +Refs: beans-t0tv" +``` + +### Task 1.2: Update List Rendering to Use Compact Format + +**Files:** +- Modify: `internal/ui/styles.go` (RenderBeanRow function) +- Modify: `internal/tui/list.go` (column width calculations) + +**Step 1: Understand current rendering** + +Read `internal/ui/styles.go` to find `RenderBeanRow()` function. Note how type and status columns are rendered (currently full names like "feature", "in-progress"). + +**Step 2: Modify RenderBeanRow to use compact format** + +In `internal/ui/styles.go`, find the type and status column rendering in `RenderBeanRow()` and replace with: + +```go +// Type column - single character +typeStr := ShortType(opts.Type) +typeCol := typeStyle.Width(3).Render(typeStr) + +// Status column - single character +statusStr := ShortStatus(opts.Status) +statusCol := statusStyle.Width(3).Render(statusStr) +``` + +**Step 3: Update column width constants** + +In `internal/ui/styles.go`, find the column width constants and update: + +```go +// Old values (approximately): +// StatusColWidth = 14 +// TypeColWidth = 12 + +// New values: +StatusColWidth = 3 +TypeColWidth = 3 +``` + +**Step 4: Update responsive column calculation** + +In `internal/ui/styles.go`, find `CalculateResponsiveColumns()` and update the base widths calculation to account for the smaller type/status columns. + +**Step 5: Run existing tests** + +Run: `go test ./internal/ui/ -v` +Run: `go test ./internal/tui/ -v` +Expected: PASS (or identify any tests that need updating) + +**Step 6: Manual test** + +Run: `mise beans` then `beans tui` +Verify the list shows single-character type and status codes. + +**Step 7: Commit** + +```bash +git add internal/ui/styles.go internal/tui/list.go +git commit -m "feat(ui): use compact single-char type/status in list + +- Type column: 3 chars (M/E/B/F/T) +- Status column: 3 chars (D/T/I/C/S) +- Frees up ~20 chars per row for title + +Refs: beans-t0tv" +``` + +--- + +## Phase 2: Detail Preview Component + +**Bean:** beans-t0tv-p2 + +Create a lightweight, read-only detail preview that can be rendered in the right pane. + +### Task 2.1: Create Detail Preview Model + +**Files:** +- Create: `internal/tui/preview.go` +- Test: `internal/tui/preview_test.go` + +**Step 1: Write the test for preview rendering** + +Create `internal/tui/preview_test.go`: + +```go +package tui + +import ( + "strings" + "testing" + + "github.com/your-org/beans/internal/bean" +) + +func TestPreviewView(t *testing.T) { + b := &bean.Bean{ + ID: "beans-test", + Title: "Test Bean", + Status: "todo", + Type: "feature", + Body: "## Summary\n\nThis is the body.", + } + + preview := newPreviewModel(b, 60, 20) + view := preview.View() + + // Should contain the title + if !strings.Contains(view, "Test Bean") { + t.Error("preview should contain bean title") + } + + // Should contain status + if !strings.Contains(view, "todo") { + t.Error("preview should contain status") + } + + // Should contain body content + if !strings.Contains(view, "Summary") { + t.Error("preview should contain body") + } +} + +func TestPreviewViewEmpty(t *testing.T) { + preview := newPreviewModel(nil, 60, 20) + view := preview.View() + + if !strings.Contains(view, "No bean selected") { + t.Error("empty preview should show 'No bean selected'") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run "TestPreview" -v` +Expected: FAIL with "undefined: newPreviewModel" + +**Step 3: Implement the preview model** + +Create `internal/tui/preview.go`: + +```go +package tui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/your-org/beans/internal/bean" + "github.com/your-org/beans/internal/ui" +) + +// previewModel is a read-only detail preview for the two-column layout. +// It has no focus, no interaction - just renders bean details. +type previewModel struct { + bean *bean.Bean + width int + height int +} + +func newPreviewModel(b *bean.Bean, width, height int) previewModel { + return previewModel{ + bean: b, + width: width, + height: height, + } +} + +func (m previewModel) View() string { + if m.bean == nil { + return m.renderEmpty() + } + return m.renderBean() +} + +func (m previewModel) renderEmpty() string { + style := lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Align(lipgloss.Center, lipgloss.Center). + Foreground(ui.ColorMuted) + + return style.Render("No bean selected") +} + +func (m previewModel) renderBean() string { + // Header: ID and Title + idStyle := lipgloss.NewStyle().Foreground(ui.ColorPrimary).Bold(true) + titleStyle := lipgloss.NewStyle().Bold(true) + + header := idStyle.Render(m.bean.ID) + "\n" + titleStyle.Render(m.bean.Title) + + // Metadata: Status, Type, Priority + metaStyle := lipgloss.NewStyle().Foreground(ui.ColorMuted) + meta := metaStyle.Render("Status: " + m.bean.Status + " Type: " + m.bean.Type) + if m.bean.Priority != "" && m.bean.Priority != "normal" { + meta += metaStyle.Render(" Priority: " + m.bean.Priority) + } + + // Body (truncated to fit) + body := m.renderBody() + + // Compose + content := lipgloss.JoinVertical(lipgloss.Left, + header, + "", + meta, + "", + body, + ) + + // Border + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorMuted). + Width(m.width - 2). + Height(m.height - 2) + + return borderStyle.Render(content) +} + +func (m previewModel) renderBody() string { + if m.bean.Body == "" { + return lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("No description") + } + + // Render markdown (reuse existing glamour renderer from detail.go) + rendered, err := renderMarkdown(m.bean.Body) + if err != nil { + return m.bean.Body + } + + // Truncate to available height + lines := strings.Split(rendered, "\n") + availableLines := m.height - 8 // Account for header, meta, borders + if len(lines) > availableLines { + lines = lines[:availableLines] + lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("...")) + } + + return strings.Join(lines, "\n") +} +``` + +Note: You may need to add `import "strings"` and export or reuse the `renderMarkdown` function from `detail.go`. + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui/ -run "TestPreview" -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/preview.go internal/tui/preview_test.go +git commit -m "feat(tui): add previewModel for read-only detail preview + +Lightweight component for two-column layout right pane: +- Shows bean ID, title, status, type, priority +- Renders markdown body (truncated to fit) +- Shows 'No bean selected' when empty + +Refs: beans-t0tv" +``` + +--- + +## Phase 3: Two-Column Layout Composition + +**Bean:** beans-t0tv-p3 + +Wire up the two-column layout in the main App, with responsive width detection. + +### Task 3.1: Add Two-Column Width Threshold + +**Files:** +- Modify: `internal/tui/tui.go` + +**Step 1: Add constants** + +Add to `internal/tui/tui.go` near the top: + +```go +const ( + // TwoColumnMinWidth is the minimum terminal width for two-column layout + TwoColumnMinWidth = 120 + // LeftPaneWidth is the fixed width of the list pane in two-column mode + LeftPaneWidth = 55 +) +``` + +**Step 2: Add helper method** + +Add to `internal/tui/tui.go`: + +```go +// isTwoColumnMode returns true if the terminal is wide enough for two-column layout +func (a *App) isTwoColumnMode() bool { + return a.width >= TwoColumnMinWidth +} +``` + +**Step 3: Commit** + +```bash +git add internal/tui/tui.go +git commit -m "feat(tui): add two-column width threshold constants + +- TwoColumnMinWidth: 120 columns +- LeftPaneWidth: 55 characters +- isTwoColumnMode() helper method + +Refs: beans-t0tv" +``` + +### Task 3.2: Add Preview State to App + +**Files:** +- Modify: `internal/tui/tui.go` + +**Step 1: Add preview field to App struct** + +In `internal/tui/tui.go`, add to the `App` struct: + +```go +type App struct { + // ... existing fields ... + + // preview is the read-only detail preview for two-column mode + preview previewModel +} +``` + +**Step 2: Initialize preview in New()** + +In the `New()` function, initialize the preview: + +```go +func New(core *beancore.Core, cfg *config.Config) *App { + // ... existing code ... + app := &App{ + // ... existing fields ... + preview: newPreviewModel(nil, 0, 0), + } + return app +} +``` + +**Step 3: Commit** + +```bash +git add internal/tui/tui.go +git commit -m "feat(tui): add preview field to App struct + +Refs: beans-t0tv" +``` + +### Task 3.3: Implement Two-Column View Rendering + +**Files:** +- Modify: `internal/tui/tui.go` + +**Step 1: Modify View() for two-column composition** + +In `internal/tui/tui.go`, find the `View()` method and modify the `viewList` case: + +```go +func (a *App) View() string { + switch a.state { + case viewList: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } + return a.list.View() + // ... rest of cases unchanged ... + } +} +``` + +**Step 2: Add renderTwoColumnView method** + +Add to `internal/tui/tui.go`: + +```go +func (a *App) renderTwoColumnView() string { + // Calculate dimensions + leftWidth := LeftPaneWidth + rightWidth := a.width - leftWidth - 3 // 3 for border/separator + height := a.height + + // Render left pane (list) + // We need to constrain the list to leftWidth + leftPane := a.list.ViewConstrained(leftWidth, height) + + // Render right pane (preview) + a.preview.width = rightWidth + a.preview.height = height - 2 // Account for footer + rightPane := a.preview.View() + + // Compose horizontally + return lipgloss.JoinHorizontal(lipgloss.Top, leftPane, rightPane) +} +``` + +**Step 3: Add ViewConstrained to listModel** + +This will be implemented in Task 3.4. + +**Step 4: Commit** + +```bash +git add internal/tui/tui.go +git commit -m "feat(tui): implement two-column view rendering + +- View() checks isTwoColumnMode() before rendering +- renderTwoColumnView() composes list + preview horizontally +- Falls back to single-column for narrow terminals + +Refs: beans-t0tv" +``` + +### Task 3.4: Add ViewConstrained to List Model + +**Files:** +- Modify: `internal/tui/list.go` + +**Step 1: Add ViewConstrained method** + +Add to `internal/tui/list.go`: + +```go +// ViewConstrained renders the list constrained to the given width and height. +// Used for the left pane in two-column mode. +func (m listModel) ViewConstrained(width, height int) string { + // Store original dimensions + origWidth := m.width + origHeight := m.height + + // Temporarily set constrained dimensions + m.width = width + m.height = height + m.list.SetSize(width-2, height-4) // Account for border and footer + + // Recalculate columns for constrained width + m.cols = ui.CalculateResponsiveColumns(width, m.hasTags) + m.updateDelegate() + + // Render + view := m.View() + + // Restore original dimensions (though this model is passed by value) + m.width = origWidth + m.height = origHeight + + return view +} +``` + +**Step 2: Test manually** + +Run: `mise beans && beans tui` +In a wide terminal (≥120 cols), verify two-column layout appears. + +**Step 3: Commit** + +```bash +git add internal/tui/list.go +git commit -m "feat(tui): add ViewConstrained for two-column list pane + +Renders list with constrained width for left pane in two-column mode. + +Refs: beans-t0tv" +``` + +--- + +## Phase 4: Cursor Sync + +**Bean:** beans-t0tv-p4 + +Detect cursor changes in the list and update the preview pane accordingly. + +### Task 4.1: Add cursorChangedMsg Type + +**Files:** +- Modify: `internal/tui/tui.go` + +**Step 1: Add message type** + +Add to `internal/tui/tui.go` with other message types: + +```go +// cursorChangedMsg is sent when the list cursor moves to a different bean +type cursorChangedMsg struct { + beanID string +} +``` + +**Step 2: Commit** + +```bash +git add internal/tui/tui.go +git commit -m "feat(tui): add cursorChangedMsg type + +Refs: beans-t0tv" +``` + +### Task 4.2: Emit cursorChangedMsg on Cursor Movement + +**Files:** +- Modify: `internal/tui/list.go` + +**Step 1: Track previous cursor index** + +In `internal/tui/list.go`, find the `Update()` method. Before delegating to `m.list.Update(msg)`, capture the current index: + +```go +func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) { + var cmds []tea.Cmd + + // Track cursor position before update + prevIndex := m.list.Index() + + // ... existing key handling ... + + // Delegate to list component + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + + // Check if cursor moved + if m.list.Index() != prevIndex { + if item, ok := m.list.SelectedItem().(beanItem); ok { + cmds = append(cmds, func() tea.Msg { + return cursorChangedMsg{beanID: item.bean.ID} + }) + } + } + + return m, tea.Batch(cmds...) +} +``` + +**Step 2: Commit** + +```bash +git add internal/tui/list.go +git commit -m "feat(tui): emit cursorChangedMsg on cursor movement + +Detects when list cursor moves and emits message with new bean ID. + +Refs: beans-t0tv" +``` + +### Task 4.3: Handle cursorChangedMsg in App + +**Files:** +- Modify: `internal/tui/tui.go` + +**Step 1: Add handler in Update()** + +In `internal/tui/tui.go`, find the `Update()` method and add a case for `cursorChangedMsg`: + +```go +case cursorChangedMsg: + // Update preview with the newly highlighted bean + if msg.beanID != "" { + bean, err := a.resolver.Query().Bean(context.Background(), msg.beanID) + if err == nil && bean != nil { + a.preview = newPreviewModel(bean, a.width-LeftPaneWidth-3, a.height-2) + } + } else { + a.preview = newPreviewModel(nil, a.width-LeftPaneWidth-3, a.height-2) + } + return a, nil +``` + +**Step 2: Also update preview on beansLoadedMsg** + +Find the `beansLoadedMsg` handler and add preview update: + +```go +case beansLoadedMsg: + // ... existing handling ... + + // Update preview with current cursor position + if item, ok := a.list.list.SelectedItem().(beanItem); ok { + a.preview = newPreviewModel(item.bean, a.width-LeftPaneWidth-3, a.height-2) + } +``` + +**Step 3: Test manually** + +Run: `mise beans && beans tui` +Move cursor with j/k - right pane should update. + +**Step 4: Commit** + +```bash +git add internal/tui/tui.go +git commit -m "feat(tui): handle cursorChangedMsg to update preview + +- Updates preview when cursor moves in list +- Also updates preview when beans are loaded + +Refs: beans-t0tv" +``` + +--- + +## Phase 5: Integration & Polish + +**Bean:** beans-t0tv-p5 + +Final integration, help overlay updates, and edge case handling. + +### Task 5.1: Update Help Overlay + +**Files:** +- Modify: `internal/tui/help.go` + +**Step 1: Update help text** + +Find the help text in `internal/tui/help.go` and ensure it reflects: +- `enter` - view bean details (opens full-screen detail) +- Remove any hierarchy drilling references +- Keep all existing shortcuts + +**Step 2: Commit** + +```bash +git add internal/tui/help.go +git commit -m "docs(tui): update help overlay for two-column layout + +Refs: beans-t0tv" +``` + +### Task 5.2: Handle Window Resize in Two-Column Mode + +**Files:** +- Modify: `internal/tui/tui.go` + +**Step 1: Update preview dimensions on resize** + +In the `tea.WindowSizeMsg` handler, add preview dimension update: + +```go +case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + + // Update preview dimensions if in two-column mode + if a.isTwoColumnMode() { + a.preview.width = a.width - LeftPaneWidth - 3 + a.preview.height = a.height - 2 + } + + // ... existing list/detail updates ... +``` + +**Step 2: Commit** + +```bash +git add internal/tui/tui.go +git commit -m "fix(tui): update preview dimensions on window resize + +Refs: beans-t0tv" +``` + +### Task 5.3: Handle Empty List State + +**Files:** +- Modify: `internal/tui/tui.go` + +**Step 1: Clear preview when list is empty** + +In the `beansLoadedMsg` handler, check for empty list: + +```go +case beansLoadedMsg: + // ... existing handling ... + + // Update preview + if len(msg.items) == 0 { + a.preview = newPreviewModel(nil, a.width-LeftPaneWidth-3, a.height-2) + } else if item, ok := a.list.list.SelectedItem().(beanItem); ok { + a.preview = newPreviewModel(item.bean, a.width-LeftPaneWidth-3, a.height-2) + } +``` + +**Step 2: Commit** + +```bash +git add internal/tui/tui.go +git commit -m "fix(tui): show empty preview when list is empty + +Refs: beans-t0tv" +``` + +### Task 5.4: Final Testing + +**Step 1: Run all tests** + +```bash +go test ./internal/tui/ -v +go test ./internal/ui/ -v +``` + +**Step 2: Manual testing checklist** + +- [ ] Wide terminal (≥120 cols): two-column layout appears +- [ ] Narrow terminal (<120 cols): single-column layout +- [ ] Cursor movement updates preview +- [ ] Enter opens full-screen detail +- [ ] Escape from detail returns to two-column +- [ ] Tag filter works +- [ ] Text filter works +- [ ] Multi-select works +- [ ] All shortcuts (p/s/t/P/b/e/y/c) work +- [ ] Resize from wide to narrow and back +- [ ] Empty list shows "No bean selected" + +**Step 3: Final commit** + +```bash +git add . +git commit -m "feat(tui): complete two-column layout implementation + +Two-column TUI layout with: +- Left pane: compact bean list (55 chars) +- Right pane: read-only detail preview +- Cursor movement updates preview automatically +- Enter opens full-screen detail view +- Responsive collapse below 120 columns +- Compact single-char type/status codes + +Refs: beans-t0tv" +``` + +--- + +## Summary + +| Phase | Bean | Description | +|-------|------|-------------| +| 1 | beans-t0tv-p1 | Compact list format (single-char type/status) | +| 2 | beans-t0tv-p2 | Detail preview component | +| 3 | beans-t0tv-p3 | Two-column layout composition | +| 4 | beans-t0tv-p4 | Cursor sync | +| 5 | beans-t0tv-p5 | Integration & polish | diff --git a/_spec/research/2025-12-28-beans-t0tv-tui-two-column-layout.md b/_spec/research/2025-12-28-beans-t0tv-tui-two-column-layout.md new file mode 100644 index 00000000..d95b49c2 --- /dev/null +++ b/_spec/research/2025-12-28-beans-t0tv-tui-two-column-layout.md @@ -0,0 +1,316 @@ +--- +date: 2025-12-28T12:00:00-08:00 +researcher: Claude +git_commit: e628ab1d4bd5f6066d76b871f4b41bda118c9c7e +branch: main +repository: beans +topic: "TUI Two-Column Layout Implementation Research" +tags: [research, tui, bubbletea, beans-t0tv] +status: complete +last_updated: 2025-12-28 +last_updated_by: Claude +--- + +# Research: TUI Two-Column Layout Implementation + +**Date**: 2025-12-28 +**Researcher**: Claude +**Git Commit**: e628ab1d4bd5f6066d76b871f4b41bda118c9c7e +**Branch**: main +**Repository**: beans +**Related Bean**: beans-t0tv + +## Research Question + +What are the relevant parts of the codebase for implementing the two-column TUI layout with hierarchical navigation (bean t0tv)? + +## Summary + +The TUI is a Bubbletea-based application in `internal/tui/` with a state machine architecture. The current implementation already has separate list and detail models, which can be adapted for a two-column layout. Key components to modify include the main `App` model (view composition), `listModel` (left pane), and `detailModel` (right pane). The existing navigation, filtering, batch selection, and editor integration can be preserved with minimal changes. + +## Detailed Findings + +### TUI Architecture Overview + +The TUI uses Bubbletea's Model-Update-View pattern with a main `App` struct that coordinates between multiple view states and sub-models. + +**Main Entry Point**: `internal/tui/tui.go` + +#### App Model Structure (`tui.go:75-104`) + +```go +type App struct { + state viewState // Current view (list, detail, picker modals) + list listModel // List view model + detail detailModel // Detail view model + // ... picker models ... + history []detailModel // Stack for back navigation + core *beancore.Core // Bean data management + resolver *graph.Resolver // GraphQL queries/mutations + width, height int // Terminal dimensions + previousState viewState // For modal backgrounds +} +``` + +#### View States (`tui.go:21-34`) + +Currently 10 view states: +- `viewList` - Main bean list +- `viewDetail` - Single bean detail (full screen) +- `viewTagPicker`, `viewParentPicker`, `viewStatusPicker`, `viewTypePicker`, `viewPriorityPicker`, `viewBlockingPicker` - Modal pickers +- `viewCreateModal`, `viewHelpOverlay` - Other modals + +**Key Insight**: The existing `viewList` and `viewDetail` states are separate full-screen views. For two-column layout, these would become panes rendered side-by-side in a single view. + +### List Model (`internal/tui/list.go`) + +#### Structure (`list.go:103-124`) + +```go +type listModel struct { + list list.Model // Bubbletea list component + resolver *graph.Resolver + config *config.Config + width, height int + hasTags bool + cols ui.ResponsiveColumns // Calculated column widths + idColWidth int // ID column width for tree depth + tagFilter string // Active tag filter + selectedBeans map[string]bool // Multi-select state + statusMessage string +} +``` + +#### Key Methods + +- `newListModel()` (`list.go:126-146`) - Creates list with custom delegate +- `Init()` (`list.go:164-165`) - Returns `loadBeans` command +- `loadBeans()` (`list.go:168-211`) - GraphQL query, tree building, flattening +- `Update()` (`list.go:228-451`) - Key handling, window sizing +- `View()` (`list.go:465-550`) - Renders list with border and footer + +#### Current Rendering + +The list renders beans in a tree structure with: +- Tree prefixes (box-drawing characters) +- Responsive columns: ID, Type, Status, Title, optional Tags +- Cursor highlighting (purple "▌") +- Multi-select highlighting (amber ID) +- Dimmed ancestors for context + +**Relevant for Two-Column**: List rendering already handles variable widths via `ui.ResponsiveColumns`. The width can be constrained to left pane width. + +### Detail Model (`internal/tui/detail.go`) + +#### Structure (`detail.go:128-141`) + +```go +type detailModel struct { + viewport viewport.Model // Scrollable body + bean *bean.Bean + resolver *graph.Resolver + config *config.Config + width, height int + ready bool + links []resolvedLink // Parent, children, blocking relationships + linkList list.Model // Filterable link list + linksActive bool // Focus: links vs body + cols ui.ResponsiveColumns + statusMessage string +} +``` + +#### Sections Rendered + +1. **Header** (`detail.go:491-527`) - Title, ID, status badge, tags +2. **Links Section** (`detail.go:419-431`) - Linked beans with focus border +3. **Body** (`detail.go:667-686`) - Markdown-rendered description in viewport + +**Relevant for Two-Column**: Detail view already handles variable sizing. Can be adapted to right pane width. The links section and viewport scrolling work independently. + +### Navigation and Hierarchy + +#### Current Navigation Flow + +1. **List → Detail**: `enter` key sends `selectBeanMsg` (`list.go:281-286`) +2. **Detail → List**: `esc`/`backspace` sends `backToListMsg` (`detail.go:298-301`) +3. **Detail → Detail**: Navigating to linked bean pushes to history (`tui.go:502-509`) +4. **Back Navigation**: Pops from history stack (`tui.go:511-523`) + +#### History Stack (`tui.go:87`) + +```go +history []detailModel // Stack of previous detail views +``` + +**Key Insight for Hierarchical Navigation**: The existing history stack pattern can be adapted. Instead of storing detail views, store the "root" bean ID for hierarchy drilling. + +### Filtering System + +#### Tag Filtering (`list.go:117`, `tui.go:200-214`) + +- Tag filter stored in `listModel.tagFilter` +- Applied at GraphQL query level via `BeanFilter{Tags: []string{tag}}` +- "g t" chord opens tag picker +- Tree building includes ancestors for context (dimmed) + +#### Text Filtering + +- Built into Bubbletea's list component +- Activated with "/" key +- Filters on `bean.Title + " " + bean.ID` + +**Relevant for Two-Column**: Filtering remains on the left pane (list). The right pane shows detail of highlighted bean. + +### Keybindings + +#### List View Keys (`list.go:269-444`) + +| Key | Action | +|-----|--------| +| `space` | Toggle multi-select, move down | +| `enter` | View bean detail | +| `p` | Open parent picker | +| `s` | Open status picker | +| `t` | Open type picker | +| `P` | Open priority picker | +| `b` | Open blocking picker | +| `c` | Create new bean | +| `e` | Edit in external editor | +| `y` | Copy bean ID(s) | +| `esc`/`backspace` | Clear selection, then filter | + +#### Detail View Keys (`detail.go:297-386`) + +| Key | Action | +|-----|--------| +| `tab` | Toggle focus: links ↔ body | +| `enter` | Navigate to linked bean | +| `p/s/t/P/b/e/y` | Same as list view | +| `esc`/`backspace` | Back to list/previous | + +**Relevant for Two-Column**: Most keys work on highlighted bean. In two-column, highlight on left pane determines what's shown on right. Same keys should work. + +### Batch Selection (`list.go:120-121`, `tui.go:246-334`) + +- `selectedBeans map[string]bool` tracks selected IDs +- Visual: Amber highlight on ID column +- Operations: status, type, priority, parent changes +- Footer shows selection count + +**Relevant for Two-Column**: Selection remains on left pane. Batch operations unaffected. + +### Editor Integration (`tui.go:414-450`) + +1. `e` key triggers `openEditorMsg` +2. Records bean ID and file mod time +3. `tea.ExecProcess()` suspends TUI, launches editor +4. On return, checks if file modified, updates `updated_at` +5. File watcher triggers `beansChangedMsg` for refresh + +**Relevant for Two-Column**: Works the same. Editor opens for highlighted bean. + +### Window Sizing (`tui.go:128-130`, `list.go:232-239`) + +- `tea.WindowSizeMsg` received on resize +- `App` stores `width`, `height` +- List/detail models update their dimensions +- Responsive columns recalculated + +**Critical for Two-Column**: Need to split width between panes. Consider: +- Fixed ratio (e.g., 40/60) +- Minimum widths for each pane +- Collapse to single pane on narrow terminals + +### Shared UI Components (`internal/ui/`) + +#### Tree Rendering (`ui/tree.go`) + +- `BuildTree()` - Creates hierarchy from beans +- `FlattenTree()` - Converts to flat list with prefixes +- `MaxTreeDepth()` - For ID column width + +#### Responsive Columns (`ui/styles.go:350-407`) + +- `CalculateResponsiveColumns()` - Computes column widths +- Tags shown only when width >= 140 +- Tag column scales 24-70 chars based on space + +#### Bean Row Rendering (`ui/styles.go:409-543`) + +- `RenderBeanRow()` - Shared between list and detail links +- Handles cursor, selection, dimming, tree prefix + +## Code References + +### Core Files to Modify + +- `internal/tui/tui.go:75-104` - App model needs new layout state +- `internal/tui/tui.go:572-608` - View() needs two-column composition +- `internal/tui/list.go:465-550` - List View() needs width constraint +- `internal/tui/detail.go:411-471` - Detail View() needs width constraint + +### Supporting Files + +- `internal/tui/keys.go` - May need new keybindings for hierarchy navigation +- `internal/ui/styles.go:350-407` - Responsive column calculation +- `internal/tui/help.go` - Update help text with new keybindings + +## Architecture Considerations + +### Proposed Two-Column Structure + +```go +type App struct { + // ... existing fields ... + + // New fields for two-column + rootBeanID string // Current hierarchy root (empty = top level) + rootHistory []string // Stack of previous roots for back navigation + leftPaneWidth int // Calculated left pane width +} +``` + +### View Composition Pattern + +The current `View()` switches between full-screen views. For two-column: + +```go +func (a *App) View() string { + if a.state == viewList { + // Two-column layout + left := a.renderLeftPane() // Constrained list + right := a.renderRightPane() // Constrained detail + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) + } + // Modal overlays work as before + return a.renderModalOverlay() +} +``` + +### Hierarchy Navigation + +- **Enter on bean**: Set as new root, list shows only children +- **Escape/Backspace**: Pop from root history, show parent's children +- **Breadcrumb**: Show path like "Root > Epic > Feature" + +### Width Handling + +Suggested approach: +- Minimum left pane: 50 chars (enough for compact tree) +- Minimum right pane: 60 chars (readable detail) +- If terminal < 110 chars: Fall back to single pane (existing behavior) +- Otherwise: 40% left, 60% right (or configurable) + +## Open Questions + +1. **Hierarchy root indicator**: Where to show breadcrumb? Above list? In list title? +2. **Empty children**: What to show when a bean has no children? +3. **Back navigation key**: Use `esc` (conflicts with filter clear) or `backspace`? +4. **Detail pane focus**: Should right pane be scrollable with j/k when focused? +5. **Responsive breakpoint**: At what terminal width to collapse to single column? + +## Related Documentation + +- `.claude/skills/bubbletea/SKILL.md` - Bubbletea framework reference +- Bean t0tv checklist in `.beans/beans-t0tv--*.md` diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 2fcda65e..5e17e0ac 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -118,6 +118,7 @@ func (d linkDelegate) Render(w io.Writer, m list.Model, index int, listItem list ShowTags: d.cols.ShowTags, TagsColWidth: d.cols.Tags, MaxTags: d.cols.MaxTags, + UseFullNames: true, // Full type/status names in detail view }, ) diff --git a/internal/tui/help.go b/internal/tui/help.go index 581baf86..beed8d2b 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -79,6 +79,7 @@ func (m helpOverlayModel) View() string { var content strings.Builder content.WriteString(title + "\n\n") + content.WriteString(shortcut("enter", "View bean details") + "\n") content.WriteString(shortcut("b", "Manage blocking") + "\n") content.WriteString(shortcut("c", "Create new bean") + "\n") content.WriteString(shortcut("e", "Edit in $EDITOR") + "\n") diff --git a/internal/tui/list.go b/internal/tui/list.go index edb66f04..aca94150 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -93,6 +93,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list TreePrefix: item.treePrefix, Dimmed: !item.matched, IDColWidth: d.idColWidth, + UseFullNames: d.cols.UseFullTypeStatus, }, ) @@ -227,6 +228,10 @@ func (m *listModel) hasActiveFilter() bool { func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) { var cmd tea.Cmd + var cmds []tea.Cmd + + // Track cursor position before update + prevIndex := m.list.Index() switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -447,7 +452,20 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) { // Always forward to the list component m.list, cmd = m.list.Update(msg) - return m, cmd + if cmd != nil { + cmds = append(cmds, cmd) + } + + // Check if cursor moved and emit message + if m.list.Index() != prevIndex { + if item, ok := m.list.SelectedItem().(beanItem); ok { + cmds = append(cmds, func() tea.Msg { + return cursorChangedMsg{beanID: item.bean.ID} + }) + } + } + + return m, tea.Batch(cmds...) } // updateDelegate updates the list delegate with current responsive columns @@ -479,16 +497,24 @@ func (m listModel) View() string { m.list.Title = "Beans" } - // Simple bordered container + // Inner height: total height minus border (2) minus footer (1) minus padding (1) + return m.viewContent(m.height-4) + "\n" + m.Footer() +} + +// viewContent renders just the bordered list without footer. +// innerHeight is the content height inside the border (not including border lines). +func (m listModel) viewContent(innerHeight int) string { border := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(ui.ColorMuted). Width(m.width - 2). - Height(m.height - 4) + Height(innerHeight) - content := border.Render(m.list.View()) + return border.Render(m.list.View()) +} - // Footer - show different help based on filter/selection state +// Footer renders the help/status footer for the list view. +func (m listModel) Footer() string { var help string // Show selection count if any beans are selected @@ -547,6 +573,32 @@ func (m listModel) View() string { footer += help } - return content + "\n" + footer + return footer +} + +// ViewConstrained renders the list constrained to the given width and height. +// Used for the left pane in two-column mode. Returns only the content without footer. +// The output will be exactly `height` lines tall. +func (m listModel) ViewConstrained(width, height int) string { + // Temporarily set constrained dimensions + m.width = width + m.height = height + + // Inner height for border content (height minus 2 for top/bottom border) + innerHeight := height - 2 + m.list.SetSize(width-2, innerHeight) + + // Recalculate columns for constrained width + m.cols = ui.CalculateResponsiveColumns(width, m.hasTags) + m.updateDelegate() + + // Update title based on active filter + if m.tagFilter != "" { + m.list.Title = fmt.Sprintf("Beans [tag: %s]", m.tagFilter) + } else { + m.list.Title = "Beans" + } + + return m.viewContent(innerHeight) } diff --git a/internal/tui/preview.go b/internal/tui/preview.go new file mode 100644 index 00000000..3fab00b4 --- /dev/null +++ b/internal/tui/preview.go @@ -0,0 +1,144 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/hmans/beans/internal/bean" + "github.com/hmans/beans/internal/ui" +) + +// previewModel is a read-only detail preview for the two-column layout. +// It has no focus, no interaction - just renders bean details. +type previewModel struct { + bean *bean.Bean + width int + height int +} + +func newPreviewModel(b *bean.Bean, width, height int) previewModel { + return previewModel{ + bean: b, + width: width, + height: height, + } +} + +func (m previewModel) View() string { + if m.bean == nil { + return m.renderEmpty() + } + return m.renderBean() +} + +func (m previewModel) renderEmpty() string { + style := lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Align(lipgloss.Center, lipgloss.Center). + Foreground(ui.ColorMuted) + + return style.Render("No bean selected") +} + +func (m previewModel) renderBean() string { + // Header: ID and Title + idStyle := lipgloss.NewStyle().Foreground(ui.ColorPrimary).Bold(true) + titleStyle := lipgloss.NewStyle().Bold(true) + + header := idStyle.Render(m.bean.ID) + "\n" + titleStyle.Render(m.bean.Title) + + // Metadata: Status, Type, Priority + metaStyle := lipgloss.NewStyle().Foreground(ui.ColorMuted) + meta := metaStyle.Render("Status: " + m.bean.Status + " Type: " + m.bean.Type) + if m.bean.Priority != "" && m.bean.Priority != "normal" { + meta += metaStyle.Render(" Priority: " + m.bean.Priority) + } + + // Tags + var tagsLine string + if len(m.bean.Tags) > 0 { + tagsLine = ui.RenderTags(m.bean.Tags) + } + + // Body (truncated to fit) + body := m.renderBody() + + // Compose + var parts []string + parts = append(parts, header) + parts = append(parts, "") + parts = append(parts, meta) + if tagsLine != "" { + parts = append(parts, tagsLine) + } + parts = append(parts, "") + parts = append(parts, body) + + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + + // Truncate content to fit within available height + // Border takes 2 lines (top + bottom), padding takes 0 vertical + innerHeight := m.height - 2 + contentLines := strings.Split(content, "\n") + if len(contentLines) > innerHeight { + contentLines = contentLines[:innerHeight] + } + content = strings.Join(contentLines, "\n") + + // Border - use exact height to prevent overflow + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorMuted). + Padding(0, 1). + Width(m.width - 2). + Height(innerHeight) + + result := borderStyle.Render(content) + + // Ensure output is exactly m.height lines + // When truncating, preserve the bottom border (last line) + resultLines := strings.Split(result, "\n") + if len(resultLines) > m.height { + // Keep first (m.height-1) lines + the last line (bottom border) + bottomBorder := resultLines[len(resultLines)-1] + resultLines = resultLines[:m.height-1] + resultLines = append(resultLines, bottomBorder) + result = strings.Join(resultLines, "\n") + } + + return result +} + +func (m previewModel) renderBody() string { + if m.bean.Body == "" { + return lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("No description") + } + + // Render markdown (reuse existing glamour renderer from detail.go) + renderer := getGlamourRenderer() + if renderer == nil { + return m.bean.Body + } + + rendered, err := renderer.Render(m.bean.Body) + if err != nil { + return m.bean.Body + } + + // Truncate to available height + lines := strings.Split(rendered, "\n") + // Account for header (2 lines), blank line, meta (1 line), tags (0-1 line), blank line, borders/padding + // Estimate ~8 lines for header/meta + availableLines := m.height - 8 + if availableLines < 1 { + availableLines = 1 + } + + if len(lines) > availableLines { + lines = lines[:availableLines] + lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("...")) + } + + return strings.TrimSpace(strings.Join(lines, "\n")) +} diff --git a/internal/tui/preview_test.go b/internal/tui/preview_test.go new file mode 100644 index 00000000..b336f105 --- /dev/null +++ b/internal/tui/preview_test.go @@ -0,0 +1,113 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/hmans/beans/internal/bean" +) + +func TestPreviewView(t *testing.T) { + b := &bean.Bean{ + ID: "beans-test", + Title: "Test Bean", + Status: "todo", + Type: "feature", + Priority: "high", + Tags: []string{"frontend", "design"}, + Body: "## Summary\n\nThis is the body.", + } + + preview := newPreviewModel(b, 60, 20) + view := preview.View() + + // Should contain the title + if !strings.Contains(view, "Test Bean") { + t.Error("preview should contain bean title") + } + + // Should contain the ID + if !strings.Contains(view, "beans-test") { + t.Error("preview should contain bean ID") + } + + // Should contain status + if !strings.Contains(view, "todo") { + t.Error("preview should contain status") + } + + // Should contain type + if !strings.Contains(view, "feature") { + t.Error("preview should contain type") + } + + // Should contain body content + if !strings.Contains(view, "Summary") { + t.Error("preview should contain body") + } +} + +func TestPreviewViewEmpty(t *testing.T) { + preview := newPreviewModel(nil, 60, 20) + view := preview.View() + + if !strings.Contains(view, "No bean selected") { + t.Error("empty preview should show 'No bean selected'") + } +} + +func TestPreviewViewWithTags(t *testing.T) { + b := &bean.Bean{ + ID: "beans-test", + Title: "Bean with Tags", + Status: "in-progress", + Type: "bug", + Tags: []string{"urgent", "backend"}, + Body: "Test body", + } + + preview := newPreviewModel(b, 60, 20) + view := preview.View() + + // Should show tags + if !strings.Contains(view, "urgent") || !strings.Contains(view, "backend") { + t.Error("preview should display tags") + } +} + +func TestPreviewViewWithPriority(t *testing.T) { + b := &bean.Bean{ + ID: "beans-test", + Title: "High Priority Bean", + Status: "todo", + Type: "task", + Priority: "critical", + Body: "Important work", + } + + preview := newPreviewModel(b, 60, 20) + view := preview.View() + + // Should show priority + if !strings.Contains(view, "critical") { + t.Error("preview should display priority when not normal") + } +} + +func TestPreviewViewEmptyBody(t *testing.T) { + b := &bean.Bean{ + ID: "beans-test", + Title: "Bean without body", + Status: "todo", + Type: "task", + Body: "", + } + + preview := newPreviewModel(b, 60, 20) + view := preview.View() + + // Should show placeholder for empty body + if !strings.Contains(view, "No description") { + t.Error("preview should show 'No description' for empty body") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a29f3469..07ed7e12 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -11,6 +11,7 @@ import ( "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/hmans/beans/internal/beancore" "github.com/hmans/beans/internal/config" "github.com/hmans/beans/internal/graph" @@ -33,9 +34,31 @@ const ( viewHelpOverlay ) +// Two-column layout constants +const ( + TwoColumnMinWidth = 120 // minimum terminal width for two-column layout + RightPaneMaxWidth = 80 // max width of preview pane (text files follow 80 char convention) +) + +// calculatePaneWidths returns (leftWidth, rightWidth) for two-column layout. +// Right pane is capped at RightPaneMaxWidth, left pane gets remaining space. +func calculatePaneWidths(totalWidth int) (int, int) { + rightWidth := RightPaneMaxWidth + if totalWidth-rightWidth < 40 { // ensure left pane has reasonable minimum + rightWidth = totalWidth - 40 + } + leftWidth := totalWidth - rightWidth - 1 // 1 for separator + return leftWidth, rightWidth +} + // beansChangedMsg is sent when beans change on disk (via file watcher) type beansChangedMsg struct{} +// cursorChangedMsg is sent when the list cursor moves to a different bean +type cursorChangedMsg struct { + beanID string +} + // openTagPickerMsg requests opening the tag picker type openTagPickerMsg struct{} @@ -76,6 +99,7 @@ type App struct { state viewState list listModel detail detailModel + preview previewModel tagPicker tagPickerModel parentPicker parentPickerModel statusPicker statusPickerModel @@ -112,6 +136,7 @@ func New(core *beancore.Core, cfg *config.Config) *App { resolver: resolver, config: cfg, list: newListModel(resolver, cfg), + preview: newPreviewModel(nil, 0, 0), } } @@ -120,6 +145,11 @@ func (a *App) Init() tea.Cmd { return a.list.Init() } +// isTwoColumnMode returns true if the terminal width supports two-column layout +func (a *App) isTwoColumnMode() bool { + return a.width >= TwoColumnMinWidth +} + // Update handles messages func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd @@ -129,6 +159,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.width = msg.Width a.height = msg.Height + // Update preview dimensions if in two-column mode + if a.isTwoColumnMode() { + _, rightWidth := calculatePaneWidths(a.width) + a.preview.width = rightWidth + a.preview.height = a.height - 2 + } + case tea.KeyMsg: // Clear status messages on any keypress a.list.statusMessage = "" @@ -180,6 +217,31 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + case cursorChangedMsg: + // Update preview with the newly highlighted bean + _, rightWidth := calculatePaneWidths(a.width) + if msg.beanID != "" { + bean, err := a.resolver.Query().Bean(context.Background(), msg.beanID) + if err == nil && bean != nil { + a.preview = newPreviewModel(bean, rightWidth, a.height-2) + } + } else { + a.preview = newPreviewModel(nil, rightWidth, a.height-2) + } + return a, nil + + case beansLoadedMsg: + // Forward to list view + a.list, cmd = a.list.Update(msg) + // Update preview with current cursor position + _, rightWidth := calculatePaneWidths(a.width) + if len(msg.items) == 0 { + a.preview = newPreviewModel(nil, rightWidth, a.height-2) + } else if item, ok := a.list.list.SelectedItem().(beanItem); ok { + a.preview = newPreviewModel(item.bean, rightWidth, a.height-2) + } + return a, cmd + case beansChangedMsg: // Beans changed on disk - refresh if a.state == viewDetail { @@ -568,10 +630,35 @@ func (a *App) collectTagsWithCounts() []tagWithCount { return tags } +// renderTwoColumnView renders the list and preview side by side with app-global footer +func (a *App) renderTwoColumnView() string { + leftWidth, rightWidth := calculatePaneWidths(a.width) + contentHeight := a.height - 1 // Reserve 1 line for footer + + // Render left pane (list) with constrained width, no footer + leftPane := a.list.ViewConstrained(leftWidth, contentHeight) + + // Render right pane (preview) with same height + a.preview.width = rightWidth + a.preview.height = contentHeight + rightPane := a.preview.View() + + // Compose columns + columns := lipgloss.JoinHorizontal(lipgloss.Top, leftPane, rightPane) + + // App-global footer spans full width + footer := a.list.Footer() + + return columns + "\n" + footer +} + // View renders the current view func (a *App) View() string { switch a.state { case viewList: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } return a.list.View() case viewDetail: return a.detail.View() diff --git a/internal/ui/styles.go b/internal/ui/styles.go index c4213da9..624a6039 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -276,6 +276,42 @@ func RenderPriorityText(priority, color string) string { return style.Render(priority) } +// ShortType returns a single-character code for the bean type. +func ShortType(t string) string { + switch t { + case "milestone": + return "M" + case "epic": + return "E" + case "bug": + return "B" + case "feature": + return "F" + case "task": + return "T" + default: + return "?" + } +} + +// ShortStatus returns a single-character code for the bean status. +func ShortStatus(s string) string { + switch s { + case "draft": + return "D" + case "todo": + return "T" + case "in-progress": + return "I" + case "completed": + return "C" + case "scrapped": + return "S" + default: + return "?" + } +} + // GetPrioritySymbol returns the raw symbol for a priority without styling. // Returns empty string for normal/empty priority. func GetPrioritySymbol(priority string) string { @@ -327,24 +363,26 @@ type BeanRowConfig struct { TreePrefix string // Tree prefix (e.g., "├─" or " └─") to prepend to ID Dimmed bool // Render row dimmed (for unmatched ancestor beans in tree) IDColWidth int // Width of ID column (0 = default of ColWidthID) + UseFullNames bool // Use full type/status names instead of single-char abbreviations } // Base column widths for bean lists (minimum sizes) const ( ColWidthID = 12 - ColWidthStatus = 14 - ColWidthType = 12 + ColWidthStatus = 3 + ColWidthType = 3 ColWidthTags = 24 ) // ResponsiveColumns holds calculated column widths based on available space type ResponsiveColumns struct { - ID int - Status int - Type int - Tags int - MaxTags int // How many tags to show - ShowTags bool + ID int + Status int + Type int + Tags int + MaxTags int // How many tags to show + ShowTags bool + UseFullTypeStatus bool // Use full names instead of single-char abbreviations } // CalculateResponsiveColumns determines column widths based on available width. @@ -359,6 +397,14 @@ func CalculateResponsiveColumns(totalWidth int, hasTags bool) ResponsiveColumns ShowTags: false, } + // Use full type/status names when terminal is wide enough + const minWidthForFullNames = 120 + if totalWidth >= minWidthForFullNames { + cols.UseFullTypeStatus = true + cols.Status = 12 // "in-progress" needs 11 chars + cols.Type = 10 // "milestone" needs 9 chars + } + // Don't show tags in narrow viewports - prioritize title space // Only consider showing tags if terminal is wide enough (140+ columns) const minWidthForTags = 140 @@ -368,7 +414,7 @@ func CalculateResponsiveColumns(totalWidth int, hasTags bool) ResponsiveColumns } // At this point we have at least 140 columns - // Base usage: cursor (2) + ID (12) + status (14) + type (12) = 40 + // Base usage: cursor (2) + ID + status + type (use responsive widths) cursorWidth := 2 baseWidth := cursorWidth + cols.ID + cols.Status + cols.Type available := totalWidth - baseWidth @@ -448,22 +494,34 @@ func RenderBeanRow(id, status, typeName, title string, cfg BeanRowConfig) string idCol = TreeLine.Render(cfg.TreePrefix) + ID.Render(id) + padding } + // Type column - single character or full name + var typeStr string + if cfg.UseFullNames { + typeStr = typeName + typeStyle = typeStyle.Width(12) // wider for full names + } else { + typeStr = ShortType(typeName) + } var typeCol string - if typeName != "" { - if cfg.Dimmed { - typeCol = typeStyle.Render(Muted.Render(typeName)) - } else { - typeCol = typeStyle.Render(RenderTypeText(typeName, cfg.TypeColor)) - } + if cfg.Dimmed { + typeCol = typeStyle.Render(Muted.Render(typeStr)) } else { - typeCol = typeStyle.Render("") + typeCol = typeStyle.Render(RenderTypeText(typeStr, cfg.TypeColor)) } + // Status column - single character or full name + var statusStr string + if cfg.UseFullNames { + statusStr = status + statusStyle = statusStyle.Width(12) // wider for full names + } else { + statusStr = ShortStatus(status) + } var statusCol string if cfg.Dimmed { - statusCol = statusStyle.Render(Muted.Render(status)) + statusCol = statusStyle.Render(Muted.Render(statusStr)) } else { - statusCol = statusStyle.Render(RenderStatusTextWithColor(status, cfg.StatusColor, cfg.IsArchive)) + statusCol = statusStyle.Render(RenderStatusTextWithColor(statusStr, cfg.StatusColor, cfg.IsArchive)) } // Tags column (optional) diff --git a/internal/ui/styles_test.go b/internal/ui/styles_test.go index 8251f039..04afb917 100644 --- a/internal/ui/styles_test.go +++ b/internal/ui/styles_test.go @@ -85,3 +85,49 @@ func TestRenderBeanRow_NarrowWidthWithPriority(t *testing.T) { }) } } + +func TestShortType(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"milestone", "M"}, + {"epic", "E"}, + {"bug", "B"}, + {"feature", "F"}, + {"task", "T"}, + {"unknown", "?"}, + {"", "?"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := ShortType(tt.input) + if result != tt.expected { + t.Errorf("ShortType(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestShortStatus(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"draft", "D"}, + {"todo", "T"}, + {"in-progress", "I"}, + {"completed", "C"}, + {"scrapped", "S"}, + {"unknown", "?"}, + {"", "?"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := ShortStatus(tt.input) + if result != tt.expected { + t.Errorf("ShortStatus(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/internal/ui/tree.go b/internal/ui/tree.go index 2d303c6c..f1b36783 100644 --- a/internal/ui/tree.go +++ b/internal/ui/tree.go @@ -203,8 +203,8 @@ func RenderTree(nodes []*TreeNode, cfg *config.Config, maxIDWidth int, hasTags b // Header with manual padding (lipgloss Width doesn't handle styled strings well) headerCol := lipgloss.NewStyle().Foreground(ColorMuted) idHeader := headerCol.Render("ID") + strings.Repeat(" ", treeColWidth-2) - typeHeader := headerCol.Render("TYPE") + strings.Repeat(" ", ColWidthType-4) - statusHeader := headerCol.Render("STATUS") + strings.Repeat(" ", ColWidthStatus-6) + typeHeader := headerCol.Render("T") + strings.Repeat(" ", ColWidthType-1) + statusHeader := headerCol.Render("S") + strings.Repeat(" ", ColWidthStatus-1) header := idHeader + typeHeader + statusHeader + headerCol.Render("TITLE") if cols.ShowTags && titleWidth > 5 {