diff --git a/.beans/beans-238n--task-2-delete-previewgo-and-update-app-struct.md b/.beans/beans-238n--task-2-delete-previewgo-and-update-app-struct.md new file mode 100644 index 00000000..0555da5e --- /dev/null +++ b/.beans/beans-238n--task-2-delete-previewgo-and-update-app-struct.md @@ -0,0 +1,112 @@ +--- +# beans-238n +title: 'Task 2: Delete preview.go and update App struct' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:35:44Z +updated_at: 2025-12-30T17:10:38Z +parent: beans-pn6z +--- + +## Overview + +Remove the preview model since we will use detailModel in the right pane. + +## Files + +- Delete: `internal/tui/preview.go` +- Modify: `internal/tui/tui.go:98-128` (App struct) + +## Steps + +### Step 1: Delete preview.go + +```bash +rm internal/tui/preview.go +``` + +### Step 2: Remove preview field from App struct + +In `tui.go`, remove from App struct: +```go +// Remove this line: +preview previewModel +``` + +### Step 3: Remove preview initialization in New() + +Remove: +```go +preview: newPreviewModel(nil, 0, 0), +``` + +### Step 4: Comment out preview-related code temporarily + +Comment out these specific blocks (will be replaced in Task 5 - beans-s65d): + +**In `tea.WindowSizeMsg` handler (~line 162-167):** +```go +// Comment out: +// if a.isTwoColumnMode() { +// _, rightWidth := calculatePaneWidths(a.width) +// a.preview.width = rightWidth +// a.preview.height = a.height - 2 +// } +``` + +**In `cursorChangedMsg` handler (~line 220-231):** +```go +// Comment out entire case: +// case cursorChangedMsg: +// _, 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 +``` + +**In `beansLoadedMsg` handler (~line 237-242):** +```go +// Comment out preview update (keep the list update): +// _, 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) +// } +``` + +**In `renderTwoColumnView()` (~line 641-644):** +```go +// Comment out preview rendering: +// a.preview.width = rightWidth +// a.preview.height = contentHeight +// rightPane := a.preview.View() + +// Temporarily replace with placeholder: +rightPane := "Detail placeholder" +``` + +### Step 5: Build and verify + +Run: `mise build` +Expected: Build succeeds (commented code will be replaced later) + +### Step 6: Commit + +```bash +git add -A +git commit -m "refactor(tui): remove preview.go, use detailModel in right pane + +- Delete preview.go (no longer needed) +- Remove preview field from App struct +- Comment out preview references (will be replaced with detail) + +Refs: beans-pn6z" +``` \ No newline at end of file diff --git a/.beans/beans-2v6j--task-3-add-focus-parameter-to-list-border-renderin.md b/.beans/beans-2v6j--task-3-add-focus-parameter-to-list-border-renderin.md new file mode 100644 index 00000000..1025b522 --- /dev/null +++ b/.beans/beans-2v6j--task-3-add-focus-parameter-to-list-border-renderin.md @@ -0,0 +1,88 @@ +--- +# beans-2v6j +title: 'Task 3: Add focus parameter to list border rendering' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:36:00Z +updated_at: 2025-12-30T17:18:32Z +parent: beans-pn6z +--- + +## Overview + +Update list.go to accept a focus parameter that controls border color. + +## Files + +- Modify: `internal/tui/list.go:504-514` (viewContent method) +- Modify: `internal/tui/list.go:582-603` (ViewConstrained method) + +## Steps + +### Step 1: Add focused parameter to viewContent + +Update the `viewContent` method signature and implementation: + +```go +// viewContent renders just the bordered list without footer. +// innerHeight is the content height inside the border (not including border lines). +// focused determines the border color (primary when focused, muted when not). +func (m listModel) viewContent(innerHeight int, focused bool) string { + borderColor := ui.ColorMuted + if focused { + borderColor = ui.ColorPrimary + } + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(m.width - 2). + Height(innerHeight) + + return border.Render(m.list.View()) +} +``` + +### Step 2: Update View() to pass focused=true + +In `View()`, update the call: +```go +return m.viewContent(m.height-4, true) + "\n" + m.Footer() +``` + +### Step 3: Add focused parameter to ViewConstrained + +Update signature: +```go +func (m listModel) ViewConstrained(width, height int, focused bool) string { +``` + +Update the return: +```go +return m.viewContent(innerHeight, focused) +``` + +### Step 4: Update call site in tui.go + +In `renderTwoColumnView()`, update the call (will be refactored more later): +```go +leftPane := a.list.ViewConstrained(leftWidth, contentHeight, true) // TODO: pass actual focus state +``` + +### Step 5: Build and verify + +Run: `mise build` +Expected: Build succeeds + +### Step 6: Commit + +```bash +git add internal/tui/list.go internal/tui/tui.go +git commit -m "feat(tui): add focus parameter to list border rendering + +Border color changes based on focus state: +- Primary (cyan) when focused +- Muted (gray) when not focused + +Refs: beans-pn6z" +``` \ No newline at end of file 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-65cw--use-short-view-for-statustype-in-linked-beans-comp.md b/.beans/beans-65cw--use-short-view-for-statustype-in-linked-beans-comp.md new file mode 100644 index 00000000..5be4db41 --- /dev/null +++ b/.beans/beans-65cw--use-short-view-for-statustype-in-linked-beans-comp.md @@ -0,0 +1,12 @@ +--- +# beans-65cw +title: Use short view for status/type in linked beans component +status: completed +type: bug +priority: normal +created_at: 2025-12-30T18:40:02Z +updated_at: 2025-12-30T18:42:28Z +parent: beans-pn6z +--- + +The linked bean view contains line breaks because each line (linked bean) is too long. Use the short view for state and type to make it more compact. \ No newline at end of file diff --git a/.beans/beans-6ljm--fix-linked-beans-overflow-breaking-layout.md b/.beans/beans-6ljm--fix-linked-beans-overflow-breaking-layout.md new file mode 100644 index 00000000..2bb0f5b7 --- /dev/null +++ b/.beans/beans-6ljm--fix-linked-beans-overflow-breaking-layout.md @@ -0,0 +1,51 @@ +--- +# beans-6ljm +title: Fix linked beans overflow breaking layout +status: completed +type: bug +priority: normal +created_at: 2025-12-30T18:40:03Z +updated_at: 2025-12-30T21:34:12Z +parent: beans-pn6z +--- + +When a bean has many linked beans, the view completely breaks - linked beans text overflows and wraps incorrectly, causing garbled display where list and detail panes overlap visually. Need to properly truncate or scroll the linked beans section. + +## Related fixes completed + +- **beans-65cw**: Changed `UseFullNames: false` in linked beans to use short type/status (single char) instead of full names. This fixed the overflow where lines were too long. +- **beans-e2gi**: Used `strings.TrimRight(m.linkList.View(), "\n ")` to remove empty trailing lines from the bubbles list. This worked but caused height calculation mismatches. + +## Bubbles list component research + +The `list.New(items, delegate, width, height)` height parameter is the **TOTAL height** for the entire component. The component internally divides this among: + +1. **Title bar** (1 line) - if `showTitle` is true (default: true) +2. **Status bar** - if `showStatusBar` is true (default: true) +3. **Pagination** (1 line for dots) - if `showPagination` is true (default: true) +4. **Help** - if `showHelp` is true (default: true) +5. **Items** - remaining space, calculated as: `availHeight / (delegate.Height() + delegate.Spacing())` + +### Key insight + +The height you give is NOT "number of items + title". It's the total pixel/line budget. The component subtracts space for title, pagination, etc., and gives the rest to items. + +### Correct height calculation + +```go +// For showing up to N items: +height := 1 // title +height += numItemsToShow // items (delegate.Height()=1 each) +if totalItems > numItemsToShow { + height++ // pagination dots +} +// Don't add +1 for title again - it's already counted! +``` + +### Current issue (still not working) + +The body viewport height calculation doesn't match what's actually rendered. The `calculateHeaderHeight()` tries to predict the height of header + links section, but there's still a mismatch causing the body to be too short. + +## Potential simplification + +Consider rendering linked beans manually without the bubbles list component. This gives full control over height and removes the complexity of predicting bubbles' internal layout calculations. \ 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-7vrp--task-7-update-history-navigation-for-unified-view.md b/.beans/beans-7vrp--task-7-update-history-navigation-for-unified-view.md new file mode 100644 index 00000000..dfcb6229 --- /dev/null +++ b/.beans/beans-7vrp--task-7-update-history-navigation-for-unified-view.md @@ -0,0 +1,129 @@ +--- +# beans-7vrp +title: 'Task 7: Update history navigation for unified view' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:37:42Z +updated_at: 2025-12-30T18:18:57Z +parent: beans-pn6z +--- + +## Overview + +Update the history stack to store bean IDs instead of detailModel instances, and implement navigation when following links. + +## Files + +- Modify: `internal/tui/tui.go` + +## Steps + +### Step 1: Change history type in App struct + +```go +type App struct { + // ... other fields ... + history []string // stack of bean IDs for back navigation + // Remove: history []detailModel +} +``` + +### Step 2: Add helper method to move cursor to a bean + +```go +// moveCursorToBean moves the list cursor to the bean with the given ID +func (a *App) moveCursorToBean(beanID string) { + items := a.list.list.Items() + for i, item := range items { + if bi, ok := item.(beanItem); ok && bi.bean.ID == beanID { + a.list.list.Select(i) + break + } + } +} +``` + +### Step 3: Update link navigation (Enter on link) + +In the detail models Update() or in App Update(), when a link is followed: + +```go +// In App.Update(), handle selectBeanMsg differently +case selectBeanMsg: + // Push current bean to history before navigating + if a.detail.bean \!= nil { + a.history = append(a.history, a.detail.bean.ID) + } + // Move cursor to new bean (will trigger cursorChangedMsg) + a.moveCursorToBean(msg.bean.ID) + // Stay in detail focus (links or body based on new bean) + // The detail will be recreated via cursorChangedMsg + return a, nil +``` + +### Step 4: Update Backspace handling + +Already done in Task 5, but verify: + +```go +if msg.String() == "backspace" { + if a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { + if len(a.history) > 0 { + prevBeanID := a.history[len(a.history)-1] + a.history = a.history[:len(a.history)-1] + a.moveCursorToBean(prevBeanID) + // Stay in current detail focus state + return a, nil + } + a.state = viewListFocused + return a, nil + } +} +``` + +### Step 5: Update cursorChangedMsg handler + +When cursor changes, recreate detailModel: + +```go +case cursorChangedMsg: + if msg.beanID \!= "" { + bean, err := a.resolver.Query().Bean(context.Background(), msg.beanID) + if err == nil && bean \!= nil { + // Recreate detail with current focus state + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + _, rightWidth := calculatePaneWidths(a.width) + a.detail = newDetailModel(bean, a.resolver, a.config, rightWidth, a.height-2, linksFocused, bodyFocused) + } + } + return a, nil +``` + +### Step 6: Remove old backToListMsg handling + +Remove the `backToListMsg` handler that used to pop from history - we now handle this differently. + +### Step 7: Build and test + +Run: `mise build && mise beans` +Test: +- Enter a bean with links +- Follow a link (Enter on linked bean) +- Press Backspace (should go back to previous bean) +- Press Backspace again (should return to list) + +### Step 8: Commit + +```bash +git add internal/tui/tui.go +git commit -m "feat(tui): update history navigation for unified view + +- History stores bean IDs instead of detailModel instances +- Following links pushes current bean to history +- Backspace navigates history, then returns to list +- moveCursorToBean helper for navigation + +Refs: beans-pn6z" +``` \ No newline at end of file diff --git a/.beans/beans-ck61--linked-beans-text-truncated-too-early-should-use-f.md b/.beans/beans-ck61--linked-beans-text-truncated-too-early-should-use-f.md new file mode 100644 index 00000000..bf224b82 --- /dev/null +++ b/.beans/beans-ck61--linked-beans-text-truncated-too-early-should-use-f.md @@ -0,0 +1,14 @@ +--- +# beans-ck61 +title: Linked beans text truncated too early - should use full box width +status: completed +type: bug +priority: normal +created_at: 2025-12-30T21:08:41Z +updated_at: 2025-12-30T21:32:09Z +parent: beans-pn6z +--- + +In the linked beans section, bean titles are abbreviated with '...' much earlier than necessary. The text should span the entire available width of the box instead of being cut off prematurely. + +Example: 'Fix linked beans overflow breaking ...' when there's clearly more horizontal space available. \ No newline at end of file diff --git a/.beans/beans-csnk--task-10-testing-and-polish.md b/.beans/beans-csnk--task-10-testing-and-polish.md new file mode 100644 index 00000000..115d6466 --- /dev/null +++ b/.beans/beans-csnk--task-10-testing-and-polish.md @@ -0,0 +1,85 @@ +--- +# beans-csnk +title: 'Task 10: Testing and polish' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:38:53Z +updated_at: 2025-12-30T18:18:57Z +parent: beans-pn6z +--- + +## Overview + +Final testing pass and polish to ensure everything works correctly. + +## Files + +- Modify: Various TUI files as needed + +## Steps + +### Step 1: Test all keyboard interactions + +Run: `mise build && mise beans` + +Test in wide mode (≥120 cols): +- [ ] j/k navigates list, detail updates +- [ ] Enter focuses detail (links if present, else body) +- [ ] Tab toggles between links and body +- [ ] Backspace returns to list (or navigates history) +- [ ] Edit shortcuts work from all focus states (p, s, t, P, b, e, y) +- [ ] / filters in list and links +- [ ] q quits from any state +- [ ] ? opens help +- [ ] Esc clears selection/filter (list only) +- [ ] Border colors change based on focus + +### Step 2: Test narrow mode + +Resize terminal below 120 cols: +- [ ] Only list visible by default +- [ ] Enter shows only detail +- [ ] Backspace shows only list +- [ ] All shortcuts work correctly + +### Step 3: Test history navigation + +- [ ] Follow a link (Enter on linked bean) +- [ ] Backspace goes back to previous bean +- [ ] Follow multiple links, Backspace navigates stack +- [ ] Empty history, Backspace returns to list + +### Step 4: Test edge cases + +- [ ] Empty list shows "No bean selected" in detail +- [ ] Bean with no links: Tab does nothing, Enter focuses body +- [ ] Terminal resize while in detail focus +- [ ] Open picker from detail, returns to detail on close + +### Step 5: Fix any issues found + +Document and fix any issues discovered during testing. + +### Step 6: Run tests + +```bash +mise test +``` + +Fix any failing tests. + +### Step 7: Final commit + +```bash +git add -A +git commit -m "test(tui): verify unified detail view works correctly + +Refs: beans-pn6z" +``` + +### Step 8: Update beans-pn6z status + +```bash +beans update beans-pn6z -s completed +``` \ No newline at end of file diff --git a/.beans/beans-d8ez--navigate-to-linked-bean-on-enter-in-detail-view.md b/.beans/beans-d8ez--navigate-to-linked-bean-on-enter-in-detail-view.md new file mode 100644 index 00000000..c8e9550c --- /dev/null +++ b/.beans/beans-d8ez--navigate-to-linked-bean-on-enter-in-detail-view.md @@ -0,0 +1,16 @@ +--- +# beans-d8ez +title: Navigate to linked bean on Enter in detail view +status: completed +type: task +priority: normal +created_at: 2025-12-30T21:58:18Z +updated_at: 2025-12-30T22:00:17Z +parent: beans-pn6z +--- + +When pressing Enter on a linked bean in the detail view's linked beans list, the TUI should: +1. Move the list cursor to that bean +2. Re-render the detail view showing the selected bean + +Currently `moveCursorToBean()` calls `Select()` on the bubbles list but doesn't trigger `cursorChangedMsg`, so the detail view isn't updated. \ No newline at end of file diff --git a/.beans/beans-e2gi--remove-empty-line-at-bottom-of-linked-beans-compon.md b/.beans/beans-e2gi--remove-empty-line-at-bottom-of-linked-beans-compon.md new file mode 100644 index 00000000..bba208a8 --- /dev/null +++ b/.beans/beans-e2gi--remove-empty-line-at-bottom-of-linked-beans-compon.md @@ -0,0 +1,12 @@ +--- +# beans-e2gi +title: Remove empty line at bottom of linked beans component +status: completed +type: bug +priority: normal +created_at: 2025-12-30T18:40:02Z +updated_at: 2025-12-30T18:50:49Z +parent: beans-pn6z +--- + +The linked beans component has an empty line at the bottom before the border starts. Remove it to make the component more compact. \ No newline at end of file diff --git a/.beans/beans-h8u5--dedupe-consecutive-history-entries-when-following.md b/.beans/beans-h8u5--dedupe-consecutive-history-entries-when-following.md new file mode 100644 index 00000000..015fb3a0 --- /dev/null +++ b/.beans/beans-h8u5--dedupe-consecutive-history-entries-when-following.md @@ -0,0 +1,63 @@ +--- +# beans-h8u5 +title: Dedupe consecutive history entries when following links +status: draft +type: task +priority: low +created_at: 2025-12-30T18:30:03Z +updated_at: 2025-12-30T18:34:12Z +parent: beans-pn6z +--- + +## Problem + +If a user repeatedly follows the same link back and forth, the history stack contains duplicate consecutive entries. For example: + +1. User is viewing bean A +2. User follows link to bean B (history: [A]) +3. User follows link back to bean A (history: [A, B]) +4. User follows link to bean B again (history: [A, B, A]) + +This creates a longer history than necessary. + +## Current Code + +```go +case selectBeanMsg: + // Push current bean ID to history before navigating + if a.detail.bean != nil { + a.history = append(a.history, a.detail.bean.ID) + } + // Move cursor to new bean (will trigger cursorChangedMsg) + a.moveCursorToBean(msg.bean.ID) + return a, nil +``` + +## Suggested Fix + +Check if the new bean is the same as the last history entry before pushing: + +```go +case selectBeanMsg: + if a.detail.bean != nil { + // Avoid duplicate consecutive entries + if len(a.history) == 0 || a.history[len(a.history)-1] != a.detail.bean.ID { + a.history = append(a.history, a.detail.bean.ID) + } + } + a.moveCursorToBean(msg.bean.ID) + return a, nil +``` + +## Impact + +- Works correctly without this fix, just creates unnecessary history entries +- Minor optimization, not required for correctness + +## Files + +- `internal/tui/tui.go` (selectBeanMsg handler) + +## Context + +Identified during code review of beans-pn6z (unified detail view refactoring). \ No newline at end of file diff --git a/.beans/beans-ihnz--add-teatest-framework-to-debug-layout-issues.md b/.beans/beans-ihnz--add-teatest-framework-to-debug-layout-issues.md new file mode 100644 index 00000000..e3dbfe94 --- /dev/null +++ b/.beans/beans-ihnz--add-teatest-framework-to-debug-layout-issues.md @@ -0,0 +1,102 @@ +--- +# beans-ihnz +title: Add teatest framework to debug layout issues +status: completed +type: task +priority: normal +created_at: 2025-12-30T20:22:22Z +updated_at: 2025-12-30T20:57:26Z +parent: beans-pn6z +--- + +## Problem + +The linked beans overflow bug (beans-6ljm) is difficult to debug because: +1. Layout calculations involve multiple components (header, links section, body viewport) +2. The bubbles list component has complex internal height calculations +3. Visual inspection requires manual testing at different terminal sizes +4. Regressions are easy to introduce and hard to catch + +## Proposal + +Add the `teatest` library to create automated tests that: + +1. **Capture exact rendered output** at specific terminal dimensions +2. **Golden file comparisons** to detect layout regressions +3. **Programmatic assertions** on layout properties + +## Implementation + +### 1. Add dependency + +```bash +go get github.com/charmbracelet/x/exp/teatest +``` + +### 2. Create test fixtures + +Create a test helper that sets up an App with known test beans: +- Bean with no links (simple case) +- Bean with 1-2 links (fits without scrolling) +- Bean with 5+ links (requires scrolling/pagination) +- Bean with very long titles (tests truncation) + +### 3. Write layout tests + +```go +func TestDetailLayoutWithManyLinks(t *testing.T) { + app := createTestAppWithManyLinks(t) + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(120, 40)) + + // Navigate to detail view + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + + // Wait for render + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return strings.Contains(string(bts), "Linked Beans") + }) + + tm.Send(tea.QuitMsg{}) + + // Golden file comparison - will fail if layout breaks + teatest.RequireEqualOutput(t, tm.FinalOutput(t)) +} +``` + +### 4. Test multiple resolutions + +- 80x24 (narrow - single pane mode) +- 120x40 (wide - two column mode) +- 160x50 (extra wide) + +### 5. Debug workflow + +When layout breaks: +1. Run tests with `-update` to capture current (broken) output +2. Inspect the golden file to see exactly what's rendered +3. Fix the calculation +4. Run tests again - golden file shows the fix worked +5. Commit the corrected golden file + +## Benefits + +- **Reproducible**: Same test, same output, every time +- **Visual diffing**: Golden files show exactly what changed +- **Regression prevention**: CI catches layout breaks before merge +- **Documentation**: Test fixtures document expected behavior + +## Files to create/modify + +- `internal/tui/tui_test.go` - Add teatest-based layout tests +- `internal/tui/testdata/` - Golden files for expected output +- `go.mod` - Add teatest dependency + +## Success criteria + +- [ ] teatest dependency added +- [ ] Test helper creates App with configurable test beans +- [ ] Layout tests for narrow and wide terminal modes +- [ ] Tests for beans with 0, few, and many linked beans +- [ ] Golden files committed for all test cases +- [ ] beans-6ljm can be debugged using the test output \ 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-lmk4--task-5-update-keyboard-routing-for-granular-focus.md b/.beans/beans-lmk4--task-5-update-keyboard-routing-for-granular-focus.md new file mode 100644 index 00000000..90adff43 --- /dev/null +++ b/.beans/beans-lmk4--task-5-update-keyboard-routing-for-granular-focus.md @@ -0,0 +1,176 @@ +--- +# beans-lmk4 +title: 'Task 5: Update keyboard routing for granular focus states' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:36:43Z +updated_at: 2025-12-30T17:43:01Z +parent: beans-pn6z +--- + +## Overview + +Update tui.go Update() to route keyboard events based on the new granular view states. + +## Files + +- Modify: `internal/tui/tui.go` (Update method) + +## Steps + +### Step 1: Update global key handling + +In `Update()`, update the switch for global keys (ctrl+c, ?, q): + +```go +case tea.KeyMsg: + // Clear status messages on any keypress + a.list.statusMessage = "" + a.detail.statusMessage = "" + + switch msg.String() { + case "ctrl+c": + return a, tea.Quit + case "q": + // q always quits (except when filtering) + if a.state == viewListFocused && a.list.list.FilterState() == list.Filtering { + break // let list handle it + } + if a.state == viewDetailLinksFocused && a.detail.linkList.FilterState() == list.Filtering { + break // let detail handle it + } + return a, tea.Quit + case "?": + // Open help overlay from list or detail states + if a.state == viewListFocused || a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { + a.previousState = a.state + a.helpOverlay = newHelpOverlayModel(a.width, a.height) + a.state = viewHelpOverlay + return a, a.helpOverlay.Init() + } + } +``` + +### Step 2: Add Enter handler for viewListFocused + +When Enter is pressed in list, focus the detail pane: + +```go +case tea.KeyMsg: + // ... after global keys ... + + // Handle Enter from list - focus detail + if a.state == viewListFocused && msg.String() == "enter" { + if a.list.list.FilterState() \!= list.Filtering { + if item, ok := a.list.list.SelectedItem().(beanItem); ok { + // Focus links if bean has links, else focus body + if len(a.detail.links) > 0 { + a.state = viewDetailLinksFocused + } else { + a.state = viewDetailBodyFocused + } + return a, nil + } + } + } +``` + +### Step 3: Add Tab handler for detail states + +```go + // Handle Tab in detail - toggle between links and body + if msg.String() == "tab" { + if a.state == viewDetailLinksFocused { + a.state = viewDetailBodyFocused + return a, nil + } else if a.state == viewDetailBodyFocused && len(a.detail.links) > 0 { + a.state = viewDetailLinksFocused + return a, nil + } + } +``` + +### Step 4: Add moveCursorToBean helper method + +Add this helper method to App (needed for history navigation and link following): + +```go +// moveCursorToBean moves the list cursor to the bean with the given ID. +// This triggers cursorChangedMsg which updates the detail pane. +func (a *App) moveCursorToBean(beanID string) { + items := a.list.list.Items() + for i, item := range items { + if bi, ok := item.(beanItem); ok && bi.bean.ID == beanID { + a.list.list.Select(i) + return + } + } +} +``` + +### Step 5: Add Backspace handler for detail states + +```go + // Handle Backspace in detail - navigate history or return to list + if msg.String() == "backspace" { + if a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { + // Check history first + if len(a.history) > 0 { + // Pop from history, move cursor to that bean + prevBeanID := a.history[len(a.history)-1] + a.history = a.history[:len(a.history)-1] + // Move list cursor to that bean (will trigger cursor change and detail update) + a.moveCursorToBean(prevBeanID) + // Stay in detail focus + return a, nil + } + // No history - return to list + a.state = viewListFocused + return a, nil + } + } +``` + +### Step 6: Remove old viewList/viewDetail case handling + +Remove or update the old switch cases: +- Remove `case "q":` checks for `viewDetail` (now handled above) +- Update the message forwarding at the bottom of Update() + +### Step 7: Update message forwarding + +At the bottom of Update(), route messages to appropriate model: + +```go +// Forward all messages to the current view +switch a.state { +case viewListFocused: + a.list, cmd = a.list.Update(msg) +case viewDetailLinksFocused, viewDetailBodyFocused: + a.detail, cmd = a.detail.Update(msg) +case viewTagPicker: + a.tagPicker, cmd = a.tagPicker.Update(msg) +// ... rest unchanged +} +``` + +### Step 8: Build and test + +Run: `mise build` +Expected: Build succeeds + +### Step 9: Commit + +```bash +git add internal/tui/tui.go +git commit -m "feat(tui): route keyboard events based on granular focus states + +- Enter from list focuses detail (links if present, else body) +- Tab toggles between links and body in detail +- Backspace navigates history then returns to list +- q quits from any state (except when filtering) +- ? opens help from list/detail states + +Refs: beans-pn6z" +``` \ 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-mvme--task-1-update-view-states-to-granular-focus-states.md b/.beans/beans-mvme--task-1-update-view-states-to-granular-focus-states.md new file mode 100644 index 00000000..82da4969 --- /dev/null +++ b/.beans/beans-mvme--task-1-update-view-states-to-granular-focus-states.md @@ -0,0 +1,64 @@ +--- +# beans-mvme +title: 'Task 1: Update view states to granular focus states' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:35:29Z +updated_at: 2025-12-30T16:59:55Z +parent: beans-pn6z +--- + +## Overview + +Replace `viewList` and `viewDetail` with granular focus states. + +## Files + +- Modify: `internal/tui/tui.go:22-35` + +## Steps + +### Step 1: Update viewState constants + +Replace the current view states: + +```go +// viewState represents which view is currently active +type viewState int + +const ( + viewListFocused viewState = iota + viewDetailLinksFocused + viewDetailBodyFocused + viewTagPicker + viewParentPicker + viewStatusPicker + viewTypePicker + viewBlockingPicker + viewPriorityPicker + viewCreateModal + viewHelpOverlay +) +``` + +### Step 2: Update App.state initialization + +In `New()`, change: +```go +state: viewListFocused, +``` + +### Step 3: Build and verify no compile errors + +Run: `mise build` +Expected: Build succeeds (there will be runtime issues until we update the rest) + +### Step 4: Commit + +```bash +git add internal/tui/tui.go +git commit -m "refactor(tui): replace viewList/viewDetail with granular focus states + +Refs: beans-pn6z" +``` \ No newline at end of file diff --git a/.beans/beans-o1qp--extract-common-footer-shortcuts-into-helper-functi.md b/.beans/beans-o1qp--extract-common-footer-shortcuts-into-helper-functi.md new file mode 100644 index 00000000..6bdfc88f --- /dev/null +++ b/.beans/beans-o1qp--extract-common-footer-shortcuts-into-helper-functi.md @@ -0,0 +1,39 @@ +--- +# beans-o1qp +title: Extract common footer shortcuts into helper function +status: draft +type: task +priority: low +created_at: 2025-12-30T18:30:01Z +updated_at: 2025-12-30T18:34:12Z +parent: beans-pn6z +--- + +## Problem + +The footer rendering functions in `internal/tui/tui.go` (lines 752-790) have repetitive code listing all the shortcuts. The only differences are tab/filter/navigation keys. + +## Current State + +- `renderDetailLinksFooter()` and `renderDetailBodyFooter()` repeat most shortcuts +- Common shortcuts: backspace, b, e, p, P, s, t, y, ?, q + +## Suggested Fix + +Extract common shortcuts into a helper function to reduce duplication: + +```go +func (a *App) renderCommonDetailShortcuts() string { + return helpKeyStyle.Render("backspace") + " " + helpStyle.Render("back") + " " + + helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " + + // ... etc +} +``` + +## Files + +- `internal/tui/tui.go` + +## Context + +Identified during code review of beans-pn6z (unified detail view refactoring). \ No newline at end of file diff --git a/.beans/beans-oms6--task-9-integrate-detail-model-with-app-keyboard-ro.md b/.beans/beans-oms6--task-9-integrate-detail-model-with-app-keyboard-ro.md new file mode 100644 index 00000000..5e597b58 --- /dev/null +++ b/.beans/beans-oms6--task-9-integrate-detail-model-with-app-keyboard-ro.md @@ -0,0 +1,16 @@ +--- +# beans-oms6 +title: 'Task 9: Integrate detail model with App keyboard routing' +status: scrapped +type: task +priority: normal +created_at: 2025-12-30T16:38:33Z +updated_at: 2025-12-30T16:52:50Z +parent: beans-pn6z +--- + +## CONSOLIDATED + +This task has been consolidated into **Task 8 (beans-p63f)**. + +See beans-p63f for the combined implementation. \ No newline at end of file diff --git a/.beans/beans-ozhq--task-4-update-detailgo-to-accept-focus-parameters.md b/.beans/beans-ozhq--task-4-update-detailgo-to-accept-focus-parameters.md new file mode 100644 index 00000000..673bba9c --- /dev/null +++ b/.beans/beans-ozhq--task-4-update-detailgo-to-accept-focus-parameters.md @@ -0,0 +1,138 @@ +--- +# beans-ozhq +title: 'Task 4: Update detail.go to accept focus parameters' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:36:18Z +updated_at: 2025-12-30T17:24:53Z +parent: beans-pn6z +--- + +## Overview + +Update detailModel to accept focus parameters for border rendering, and remove internal linksActive handling (will be controlled by App viewState). + +## Files + +- Modify: `internal/tui/detail.go` + +## Steps + +### Step 1: Add focus parameters to detailModel struct + +Add fields to track which section should appear focused: + +```go +type detailModel struct { + // ... existing fields ... + linksFocused bool // true = links section has primary border + bodyFocused bool // true = body section has primary border +} +``` + +### Step 2: Update newDetailModel to accept focus parameters + +```go +func newDetailModel(b *bean.Bean, resolver *graph.Resolver, cfg *config.Config, width, height int, linksFocused, bodyFocused bool) detailModel { + m := detailModel{ + bean: b, + resolver: resolver, + config: cfg, + width: width, + height: height, + ready: true, + linksFocused: linksFocused, + bodyFocused: bodyFocused, + } + // ... rest unchanged, but remove linksActive initialization ... +``` + +### Step 3: Update View() to use focus parameters for borders + +Replace the border color logic in `View()`: + +```go +// Links section (if any) +var linksSection string +if len(m.links) > 0 { + linksBorderColor := ui.ColorMuted + if m.linksFocused { + linksBorderColor = ui.ColorPrimary + } + // ... rest unchanged +} + +// Body +bodyBorderColor := ui.ColorMuted +if m.bodyFocused { + bodyBorderColor = ui.ColorPrimary +} +``` + +### Step 4: Remove linksActive from keyboard handling + +Remove the `linksActive` field entirely. The detail model no longer handles Tab internally - the App will control which section is focused via the viewState. + +Remove from Update(): +- The `case "tab":` handler that toggles linksActive +- Any references to `m.linksActive` + +The App will recreate the detailModel with appropriate focus parameters when the user presses Tab. + +### Step 5: Update all newDetailModel call sites + +Update each call in `tui.go` to pass the new focus parameters. For now, pass `false, false` - correct values will be set when integrating: + +**Line ~256 (beansChangedMsg handler):** +```go +a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height, false, false) +``` + +**Line ~325 (statusSelectedMsg handler):** +```go +a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height, false, false) +``` + +**Line ~359 (typeSelectedMsg handler):** +```go +a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height, false, false) +``` + +**Line ~393 (prioritySelectedMsg handler):** +```go +a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height, false, false) +``` + +**Line ~440 (blockingConfirmedMsg handler):** +```go +a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height, false, false) +``` + +**Line ~535 (parentSelectedMsg handler):** +```go +a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height, false, false) +``` + +**Line ~570 (selectBeanMsg handler):** +```go +a.detail = newDetailModel(msg.bean, a.resolver, a.config, a.width, a.height, false, false) +``` + +### Step 6: Build and verify + +Run: `mise build` +Expected: Build succeeds + +### Step 7: Commit + +```bash +git add internal/tui/detail.go internal/tui/tui.go +git commit -m "refactor(tui): detail accepts focus params, remove linksActive + +- Add linksFocused/bodyFocused parameters to detailModel +- Border colors controlled by focus params +- Remove internal Tab handling (App controls focus via viewState) + +Refs: beans-pn6z" +``` \ No newline at end of file diff --git a/.beans/beans-p63f--task-8-remove-esc-as-quit-clean-up-keyboard-handli.md b/.beans/beans-p63f--task-8-remove-esc-as-quit-clean-up-keyboard-handli.md new file mode 100644 index 00000000..4d49595c --- /dev/null +++ b/.beans/beans-p63f--task-8-remove-esc-as-quit-clean-up-keyboard-handli.md @@ -0,0 +1,212 @@ +--- +# beans-p63f +title: 'Task 8: Simplify detail.go and clean up keyboard handling' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:38:04Z +updated_at: 2025-12-30T18:03:53Z +parent: beans-pn6z +--- + +## Overview + +Simplify detail.go to only handle its internal concerns. App now handles focus switching, navigation, and quit. Also ensure only `q` quits the app. + +**Note:** This task consolidates what was previously Task 8 and Task 9 (beans-oms6 is now redundant). + +## Files + +- Modify: `internal/tui/detail.go` +- Modify: `internal/tui/tui.go` +- Modify: `internal/tui/list.go` + +## Steps + +### Step 1: Simplify detail.go Update method + +The detail model should only handle: +- j/k for scrolling body or navigating links (based on focus params) +- Enter to emit selectBeanMsg when on a link (only when links focused) +- Edit shortcuts (p, s, t, P, b, e, y) +- / for filtering links (only when links focused) + +Remove from detail.go Update(): +- `case "esc", "backspace":` handler (App handles navigation) +- `case "tab":` handler (App handles focus switching) +- `case "q":` handler (App handles quit) + +```go +func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + // ... existing resize handling ... + + case tea.KeyMsg: + // If links focused and filtering, let link list handle all keys + if m.linksFocused && m.linkList.FilterState() == list.Filtering { + m.linkList, cmd = m.linkList.Update(msg) + return m, cmd + } + + switch msg.String() { + case "enter": + // Navigate to selected link (only when links focused) + if m.linksFocused { + if item, ok := m.linkList.SelectedItem().(linkItem); ok { + targetBean := item.link.bean + return m, func() tea.Msg { + return selectBeanMsg{bean: targetBean} + } + } + } + + // Edit shortcuts - always available + case "p": + return m, func() tea.Msg { + return openParentPickerMsg{ + beanIDs: []string{m.bean.ID}, + beanTitle: m.bean.Title, + beanTypes: []string{m.bean.Type}, + currentParent: m.bean.Parent, + } + } + case "s": + return m, func() tea.Msg { + return openStatusPickerMsg{ + beanIDs: []string{m.bean.ID}, + beanTitle: m.bean.Title, + currentStatus: m.bean.Status, + } + } + case "t": + return m, func() tea.Msg { + return openTypePickerMsg{ + beanIDs: []string{m.bean.ID}, + beanTitle: m.bean.Title, + currentType: m.bean.Type, + } + } + case "P": + return m, func() tea.Msg { + return openPriorityPickerMsg{ + beanIDs: []string{m.bean.ID}, + beanTitle: m.bean.Title, + currentPriority: m.bean.Priority, + } + } + case "b": + return m, func() tea.Msg { + return openBlockingPickerMsg{ + beanID: m.bean.ID, + beanTitle: m.bean.Title, + currentBlocking: m.bean.Blocking, + } + } + case "e": + return m, func() tea.Msg { + return openEditorMsg{ + beanID: m.bean.ID, + beanPath: m.bean.Path, + } + } + case "y": + return m, func() tea.Msg { + return copyBeanIDMsg{ids: []string{m.bean.ID}} + } + } + } + + // Forward updates to the appropriate component based on focus + if m.linksFocused && len(m.links) > 0 { + m.linkList, cmd = m.linkList.Update(msg) + } else if m.bodyFocused { + m.viewport, cmd = m.viewport.Update(msg) + } + + return m, cmd +} +``` + +### Step 2: Remove footer from detail.go View() + +App renders the footer, so detail should not: + +```go +func (m detailModel) View() string { + if !m.ready { + return "Loading..." + } + + if m.bean == nil { + return m.renderEmpty() + } + + header := m.renderHeader() + + var linksSection string + if len(m.links) > 0 { + linksBorderColor := ui.ColorMuted + if m.linksFocused { + linksBorderColor = ui.ColorPrimary + } + linksBorder := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(linksBorderColor). + Width(m.width - 4) + linksSection = linksBorder.Render(m.linkList.View()) + "\n" + } + + bodyBorderColor := ui.ColorMuted + if m.bodyFocused { + bodyBorderColor = ui.ColorPrimary + } + bodyBorder := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(bodyBorderColor). + Width(m.width - 4) + body := bodyBorder.Render(m.viewport.View()) + + // No footer - App renders footer separately + return header + "\n" + linksSection + body +} +``` + +### Step 3: Verify list.go Esc behavior + +In list.go, Esc should only: +1. Clear selection if any beans selected +2. Clear filter if active + +It should NOT quit. Verify this is the case (it already is). + +### Step 4: Verify tui.go q handling + +Already done in Task 6 (beans-lmk4), but verify q only quits when not filtering. + +### Step 5: Build and test + +Run: `mise build && mise beans` +Test: +- Press Esc in list with no selection/filter (nothing should happen) +- Press Esc in detail (nothing should happen) +- Press q anywhere (should quit) +- Press Esc with selection (should clear selection) +- Press Esc with filter (should clear filter) +- Edit shortcuts (p, s, t, P, b, e, y) work in detail + +### Step 6: Commit + +```bash +git add internal/tui/detail.go internal/tui/tui.go internal/tui/list.go +git commit -m "refactor(tui): simplify detail.go, only q quits + +- Detail handles scrolling, link navigation, edit shortcuts only +- App handles focus switching (Tab), navigation (Backspace), quit (q) +- Footer rendered by App, not detail +- Esc is for cancel/clear only (list) + +Refs: beans-pn6z" +``` \ No newline at end of file diff --git a/.beans/beans-pn6z--unified-detail-view-remove-full-screen-mode.md b/.beans/beans-pn6z--unified-detail-view-remove-full-screen-mode.md new file mode 100644 index 00000000..a206f970 --- /dev/null +++ b/.beans/beans-pn6z--unified-detail-view-remove-full-screen-mode.md @@ -0,0 +1,253 @@ +--- +# beans-pn6z +title: Unified detail view (remove full-screen mode) +status: completed +type: feature +priority: normal +created_at: 2025-12-30T14:02:35Z +updated_at: 2025-12-30T20:47:27Z +parent: beans-t0tv +--- + +## Summary + +Remove the separate full-screen detail view. Instead, the detail pane is always the right side of the two-column layout, with responsive behavior based on terminal width. + +## Motivation + +The two-column layout already shows bean details on the right. Having a separate full-screen detail view is redundant. Unifying these simplifies the mental model and reduces code complexity. + +## Design + +### View States + +Replace the current `viewDetail` state with granular focus states: + +```go +const ( + viewListFocused viewState = iota + viewDetailLinksFocused + viewDetailBodyFocused + viewTagPicker + viewParentPicker + // ... other pickers unchanged +) +``` + +Single source of truth - viewState tells you exactly what's focused. + +### Interaction Model + +**Key bindings:** + +| Key | List Focused | Links Focused | Body Focused | +|-----|--------------|---------------|--------------| +| `enter` | Focus detail (links if present, else body) | Follow link | - | +| `backspace` | - | Return to list | Return to list | +| `tab` | - | Switch to body | Switch to links | +| `esc` | Clear selection/filter | Navigate history, then return to list | Navigate history, then return to list | +| `j/k` | Navigate list | Navigate links | Scroll body | +| `/` | Filter list | Filter links | - | +| `space` | Toggle select | - | - | +| `c` | Create bean | - | - | +| `p,s,t,P,b,e,y` | Edit shortcuts | Edit shortcuts | Edit shortcuts | +| `?` | Help | Help | Help | +| `q` | Quit | Quit | Quit | + +**Notes:** +- Only `q` quits the app. +- `backspace` in detail always returns to list immediately (clears history). +- `esc` in detail navigates history stack; when empty, returns to list. +- `esc` in list clears selection first, then clears filter. +- Edit shortcuts (p, s, t, P, b, e, y) work from all three focus states. + +### History Navigation + +When following a link (Enter on a linked bean): +1. Push current bean to history stack +2. Move list cursor to linked bean +3. Detail pane updates automatically (recreated on cursor change) +4. Switch to list focus for continued navigation + +When pressing Escape in detail: +1. If history not empty → pop from history, move cursor to that bean, stay in detail +2. If history empty → return to list + +When pressing Backspace in detail: +- Always return to list immediately (clears history) + +### Visual Indication + +- Border color shows focus: primary (cyan) when focused, muted (gray) when not +- Applied to: list pane border, detail links section border, detail body section border +- Both panes already have borders - just change colors based on focus + +### Layout Behavior + +**Wide terminal (≥120 columns):** +- Both panes visible simultaneously +- Focus determines which pane receives keyboard input +- Unfocused pane still visible but non-interactive + +**Narrow terminal (<120 columns):** +- Only one pane visible at a time +- Default: list pane visible (detail width = 0) +- Enter: list hidden, detail visible (list width = 0) +- Backspace: detail hidden, list visible + +### Footer + +Footer changes based on focused area: + +**List focused:** +`space select · enter view · c create · / filter · esc clear · b blocking · e edit · p parent · P priority · s status · t type · y copy id · ? help · q quit` + +**Links focused:** +`tab switch · / filter · enter go to · j/k navigate · esc back · b blocking · e edit · p parent · P priority · s status · t type · y copy id · ? help · q quit` + +**Body focused:** +`tab switch · j/k scroll · esc back · b blocking · e edit · p parent · P priority · s status · t type · y copy id · ? help · q quit` + +### Picker/Modal Return + +When opening a picker (status, type, parent, etc.): +- Save current viewState to previousState +- On close, restore previousState + +This works naturally with granular view states - if you opened from `viewDetailLinksFocused`, you return to `viewDetailLinksFocused`. + +### Detail Model Updates + +- Recreate `detailModel` on every cursor change (same as current preview behavior) +- No need to preserve scroll position since it's a different bean +- Links section focus resets, which makes sense for a new bean + +### Implementation Approach + +1. Delete `preview.go` - no longer needed +2. Replace `viewDetail` with `viewListFocused`, `viewDetailLinksFocused`, `viewDetailBodyFocused` +3. Move `linksActive` logic from detailModel to App level (viewState handles it) +4. Always use `detailModel` in right pane, recreate on cursor change +5. Update keyboard routing based on viewState +6. Update border colors based on viewState +7. Update footer based on viewState +8. Keep history stack, Escape navigates history, Backspace returns to list + +### Edge Cases + +- Empty list: right pane shows "No bean selected", Enter does nothing +- Terminal resize while detail focused: if now wide, show both panes +- Link navigation: move list cursor, recreate detail, switch to list focus +- No links on bean: Tab does nothing, Enter from list focuses body directly + +### Files to Modify + +- `internal/tui/tui.go` - view states, routing, view composition, history +- `internal/tui/detail.go` - remove linksActive (handled by viewState), accept focus prop for borders +- `internal/tui/list.go` - accept focus prop for border color +- `internal/tui/preview.go` - delete + +## Design Rationale + +**Why granular view states instead of a `detailFocused` bool?** +We considered using `viewList` + `detailFocused bool`, but this creates a problem with picker return. When opening a picker, we save `previousState`. With a bool, we'd need to save/restore both viewState AND the bool separately. Granular states (`viewDetailLinksFocused`) capture everything in one place - picker return just restores the single viewState. + +**Why Escape for history navigation, Backspace for immediate return?** +Backspace provides a quick "hard reset" back to list focus, clearing history. Escape lets you step back through history one bean at a time. This gives users two options: retrace steps (Esc) or return directly to list (Backspace). + +**Why keep the history stack?** +Following a blocking relationship can jump to a bean far away in the list. Without history, you'd lose your place and have to manually scroll back. History lets you retrace your steps through linked beans. + +**Why keep all linked beans (parent, children, blocking, blocked-by)?** +Parent/children are visible in the list tree, so showing them in detail is redundant in wide mode. But in narrow mode, you can only see one pane - linked beans is the only way to see/navigate the hierarchy. Keeping it consistent across modes is simpler than conditional display. + +**Why only `q` quits?** +Esc already does multiple things (clear selection, clear filter, close modals). Adding "quit" to that list makes it unclear when Esc will quit vs do something else. Single quit key (`q`) is predictable. + +**Why keep edit shortcuts in detail view?** +You often want to change status/type/priority while looking at the full details. Forcing users to Backspace to list first adds friction. Same shortcuts in both views = less to remember. + +**Why border color for focus indication?** +Simplest option that works. Both panes already have borders. Alternatives (title highlighting, background tint) add complexity for marginal benefit. + +## Implementation Tasks + +1. **beans-mvme**: Update view states to granular focus states +2. **beans-238n**: Delete preview.go and update App struct +3. **beans-2v6j**: Add focus parameter to list border rendering +4. **beans-ozhq**: Update detail.go to accept focus parameters +5. **beans-s65d**: Update message handlers for detailModel (beansLoadedMsg, beansChangedMsg, WindowSizeMsg, empty list) +6. **beans-lmk4**: Update keyboard routing for granular focus states (includes moveCursorToBean helper) +7. **beans-unjz**: Update View() for two-column rendering with focus (includes footer rendering with statusMessage) +8. **beans-7vrp**: Update history navigation for unified view +9. **beans-p63f**: Simplify detail.go and clean up keyboard handling (consolidates former Task 9) +10. **beans-csnk**: Testing and polish + +~~**beans-oms6**: Consolidated into beans-p63f~~ + +## Out of Scope + +- Shift+Tab for reverse cycling +- Drill-down navigation (filter to children) +- Top/bottom layout alternative +- Configurable pane widths + +## Implementation Notes - Height Alignment Issues + +### Problem +The detail pane (right side) was consistently 1-2 lines shorter than the list pane (left side), causing misaligned bottom borders. + +### Root Causes Discovered + +1. **Bubbles list.View() ignores height with few items** + - When we set `list.New(items, delegate, width, height=2)` with only 1 item + - The list renders 3 lines (title + 1 item + spacing) instead of respecting height=2 + - The list only enforces height when there are enough items to paginate + - Research: bubbles uses `lipgloss.NewStyle().Height()` internally but it doesn't pad for few items + +2. **lipgloss.Place() doesn't work with styled content** + - We tried using `lipgloss.Place(width, height, ...)` to enforce exact dimensions + - But Place() is designed for "unstyled whitespace boxes" + - It doesn't properly handle ANSI codes, borders, or styled content + - Results in content being truncated or incorrectly positioned + +3. **lipgloss.Height() behavior** + - `Height(n)` sets TOTAL rendered height (including borders, excluding margins) + - ALWAYS pads with blank lines when content is shorter + - Borders add 2 lines (1 top + 1 bottom) to the total + - Use `GetVerticalFrameSize()` to calculate content area + +### Current Approach (IN PROGRESS) + +Using `lipgloss.NewStyle().Height(m.height)` instead of `Place()`: +```go +container := lipgloss.NewStyle(). + Width(m.width). + Height(m.height) +result = container.Render(result) +``` + +This should properly enforce exact height while preserving styling. + +### Status +- **Tests are failing** after this change +- Need to investigate test failure before proceeding +- The styled container approach is theoretically correct but may need adjustment + +### Files Modified +- `internal/tui/detail.go` - height calculation and rendering +- `internal/tui/tui.go` - debug logging + +### Debug Findings +From `/tmp/tui-debug.txt`: +- 0 links: Renders 44 lines (need 45) = 1 short +- 1 link: Renders 43 lines (need 45) = 2 short +- 6 links: Renders 43 lines (need 45) = 2 short + +Component breakdown shows internal math is correct (4+5+34=43, 4+9+30=43), but we're missing newlines in the joining. + +### Next Steps +1. Fix test failure caused by styled container change +2. Verify height alignment with all link scenarios (0, 1, 5+) +3. Consider alternative: manually calculate and add padding newlines instead of relying on lipgloss +4. Remove debug logging once fixed \ 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-qqbx--disable-escape-to-quit-in-main-list.md b/.beans/beans-qqbx--disable-escape-to-quit-in-main-list.md new file mode 100644 index 00000000..9b2b3954 --- /dev/null +++ b/.beans/beans-qqbx--disable-escape-to-quit-in-main-list.md @@ -0,0 +1,12 @@ +--- +# beans-qqbx +title: Disable escape-to-quit in main list +status: completed +type: bug +priority: normal +created_at: 2025-12-30T21:52:13Z +updated_at: 2025-12-30T21:52:54Z +parent: beans-pn6z +--- + +Escape quits app when in list view with no selection/filter. Only q should quit. \ No newline at end of file diff --git a/.beans/beans-s65d--task-4b-update-message-handlers-for-detailmodel.md b/.beans/beans-s65d--task-4b-update-message-handlers-for-detailmodel.md new file mode 100644 index 00000000..ff7293b4 --- /dev/null +++ b/.beans/beans-s65d--task-4b-update-message-handlers-for-detailmodel.md @@ -0,0 +1,172 @@ +--- +# beans-s65d +title: 'Task 4b: Update message handlers for detailModel' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:46:56Z +updated_at: 2025-12-30T17:32:49Z +parent: beans-pn6z +--- + +## Overview + +Update the message handlers in tui.go that were using previewModel to work with detailModel instead. + +**Note:** Do this task after Task 4 (beans-ozhq) and before Task 5 (beans-lmk4). + +## Files + +- Modify: `internal/tui/tui.go` + +## Steps + +### Step 1: Update beansLoadedMsg handler + +When beans are first loaded, create the initial detailModel: + +```go +case beansLoadedMsg: + // Forward to list view + a.list, cmd = a.list.Update(msg) + + // Create initial detailModel for selected bean + _, rightWidth := calculatePaneWidths(a.width) + if len(msg.items) == 0 { + // Empty list - create detail with nil bean + a.detail = newDetailModel(nil, a.resolver, a.config, rightWidth, a.height-2, false, false) + } else if item, ok := a.list.list.SelectedItem().(beanItem); ok { + // Default to body focused (links focused if bean has links will be set on Enter) + a.detail = newDetailModel(item.bean, a.resolver, a.config, rightWidth, a.height-2, false, false) + } + return a, cmd +``` + +### Step 2: Update beansChangedMsg handler + +Update to check for new view states: + +```go +case beansChangedMsg: + // Beans changed on disk - refresh + if a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { + // Try to reload the current bean via GraphQL + if a.detail.bean \!= nil { + updatedBean, err := a.resolver.Query().Bean(context.Background(), a.detail.bean.ID) + if err \!= nil || updatedBean == nil { + // Bean was deleted - return to list + a.state = viewListFocused + a.history = nil + } else { + // Recreate detail view with fresh bean data + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + _, rightWidth := calculatePaneWidths(a.width) + a.detail = newDetailModel(updatedBean, a.resolver, a.config, rightWidth, a.height-2, linksFocused, bodyFocused) + } + } + } + // Trigger list refresh + return a, a.list.loadBeans +``` + +### Step 3: Update WindowSizeMsg handler + +Update detail dimensions on resize: + +```go +case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + + // Update detail dimensions + _, rightWidth := calculatePaneWidths(a.width) + if a.detail.bean \!= nil { + // Preserve focus state when resizing + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + a.detail = newDetailModel(a.detail.bean, a.resolver, a.config, rightWidth, a.height-2, linksFocused, bodyFocused) + } +``` + +### Step 4: Handle empty list in Enter handler + +In the Enter handler (Task 5), add a guard: + +```go +if a.state == viewListFocused && msg.String() == "enter" { + if a.list.list.FilterState() \!= list.Filtering { + // Guard: do nothing if no bean selected + item, ok := a.list.list.SelectedItem().(beanItem) + if \!ok || item.bean == nil { + return a, nil + } + // ... rest of Enter handling + } +} +``` + +### Step 5: Update detail.go to handle nil bean + +In newDetailModel, handle nil bean gracefully: + +```go +func newDetailModel(b *bean.Bean, resolver *graph.Resolver, cfg *config.Config, width, height int, linksFocused, bodyFocused bool) detailModel { + m := detailModel{ + bean: b, + resolver: resolver, + config: cfg, + width: width, + height: height, + ready: true, + linksFocused: linksFocused, + bodyFocused: bodyFocused, + } + + if b == nil { + // Empty state - no links, empty viewport + return m + } + + // ... rest of initialization for non-nil bean +} +``` + +And update View() to show empty state: + +```go +func (m detailModel) View() string { + if m.bean == nil { + return m.renderEmpty() + } + // ... rest of View +} + +func (m detailModel) 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") +} +``` + +### Step 6: Build and verify + +Run: `mise build` +Expected: Build succeeds + +### Step 7: Commit + +```bash +git add internal/tui/tui.go internal/tui/detail.go +git commit -m "fix(tui): update message handlers for detailModel + +- beansLoadedMsg creates initial detailModel +- beansChangedMsg checks new view states +- WindowSizeMsg updates detail dimensions +- Handle nil bean gracefully (empty list) + +Refs: beans-pn6z" +``` \ No newline at end of file diff --git a/.beans/beans-svx7--split-backspace-and-escape-behavior-in-detail-view.md b/.beans/beans-svx7--split-backspace-and-escape-behavior-in-detail-view.md new file mode 100644 index 00000000..9b54b4b4 --- /dev/null +++ b/.beans/beans-svx7--split-backspace-and-escape-behavior-in-detail-view.md @@ -0,0 +1,12 @@ +--- +# beans-svx7 +title: Split backspace and escape behavior in detail view +status: completed +type: task +priority: normal +created_at: 2025-12-30T21:38:32Z +updated_at: 2025-12-30T21:41:55Z +parent: beans-pn6z +--- + +Backspace always returns to list view. Escape navigates history stack (when empty, returns to list). \ 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-u0md--update-beans-pn6z-design-doc-and-fix-selectbeanmsg.md b/.beans/beans-u0md--update-beans-pn6z-design-doc-and-fix-selectbeanmsg.md new file mode 100644 index 00000000..7daf6b0b --- /dev/null +++ b/.beans/beans-u0md--update-beans-pn6z-design-doc-and-fix-selectbeanmsg.md @@ -0,0 +1,13 @@ +--- +# beans-u0md +title: Update beans-pn6z design doc and fix selectBeanMsg location +status: completed +type: task +priority: normal +created_at: 2025-12-30T22:27:28Z +updated_at: 2025-12-30T22:30:09Z +parent: beans-pn6z +--- + +1. Update design doc to match implementation (backspace/escape, link navigation) +2. Move selectBeanMsg from list.go to tui.go \ No newline at end of file diff --git a/.beans/beans-u4dr--improve-readme-and-create-documentation.md b/.beans/beans-u4dr--improve-readme-and-create-documentation.md new file mode 100644 index 00000000..f7a39c93 --- /dev/null +++ b/.beans/beans-u4dr--improve-readme-and-create-documentation.md @@ -0,0 +1,92 @@ +--- +# beans-u4dr +title: Improve README and create documentation +status: todo +type: task +created_at: 2025-12-30T17:28:32Z +updated_at: 2025-12-30T17:28:32Z +--- + +Create dedicated documentation for beans. + +## Design + +### Create `docs/beans.md` + +A single reference document covering bean file format, configuration, and concepts. + +#### Structure + +1. **Bean File Format** + - Location: `.beans/` directory + - Filename: `--.md` (slug is optional) + - YAML frontmatter + markdown body + +2. **Frontmatter Fields** + - `title` (required) - The bean's title + - `status` (required) - Current lifecycle status + - `type` - What kind of work this represents + - `priority` - Urgency level + - `tags` - List of tags for categorization + - `parent` - Parent bean ID for hierarchy + - `blocking` - List of bean IDs this bean blocks + - `created_at` / `updated_at` - Timestamps + +3. **Statuses** (lifecycle states) + + | Status | Description | Archivable | + |--------|-------------|------------| + | in-progress | Currently being worked on | No | + | todo | Ready to be worked on | No | + | draft | Needs refinement before it can be worked on | No | + | completed | Finished successfully | Yes | + | scrapped | Will not be done | Yes | + + - New beans typically start as `todo` or `draft` + - Move to `in-progress` when work begins + - End at `completed` or `scrapped` + - Archivable statuses can be cleaned up with `beans archive` + +4. **Types** (what kind of work) + + | Type | Description | Typical Use | + |------|-------------|-------------| + | milestone | A target release or checkpoint | Group work that should ship together | + | epic | A thematic container for related work | Should have child beans, not worked on directly | + | feature | A user-facing capability or enhancement | Deliverable functionality | + | bug | Something that is broken and needs fixing | Defects, regressions | + | task | A concrete piece of work to complete | Chores, sub-tasks for features | + + Hierarchy: `milestone → epic → feature → task/bug` + +5. **Priorities** (urgency levels) + + | Priority | Description | + |----------|-------------| + | critical | Urgent, blocking work - address immediately | + | high | Important, should be done before normal work | + | normal | Standard priority | + | low | Less important, can be delayed | + | deferred | Explicitly pushed back | + +6. **Relationships** + - Parent/child hierarchy via `parent` field + - Blocking relationships via `blocking` field + +7. **Configuration** (`.beans.yml`) + - `beans.path` - Directory for bean files (default: `.beans`) + - `beans.prefix` - ID prefix (e.g., `myproj-`) + - `beans.id_length` - Length of generated IDs (default: 4) + - `beans.default_status` - Default status for new beans (default: `todo`) + - `beans.default_type` - Default type for new beans (default: `task`) + +8. **Tags** + - Format: lowercase, letters/numbers/hyphens + - Must start with a letter + - No consecutive hyphens or trailing hyphens + +### Update README + +- Add brief list of statuses and types (names only) +- Link to `docs/beans.md` for full reference +- Keep README focused on quick start / overview \ No newline at end of file diff --git a/.beans/beans-u9vk--add-error-handling-to-movecursortobean-helper.md b/.beans/beans-u9vk--add-error-handling-to-movecursortobean-helper.md new file mode 100644 index 00000000..361deff4 --- /dev/null +++ b/.beans/beans-u9vk--add-error-handling-to-movecursortobean-helper.md @@ -0,0 +1,55 @@ +--- +# beans-u9vk +title: Add error handling to moveCursorToBean helper +status: draft +type: task +priority: low +created_at: 2025-12-30T18:30:02Z +updated_at: 2025-12-30T18:34:12Z +parent: beans-pn6z +--- + +## Problem + +The `moveCursorToBean()` function in `internal/tui/tui.go` (lines 678-686) silently fails if the bean is not found in the list. This could happen if: + +- The bean was deleted but still in history +- The list is filtered and the bean is not visible +- The bean hasn't been loaded yet + +When this happens, the cursor doesn't move, no `cursorChangedMsg` is triggered, and the detail pane shows stale data. + +## Current Code + +```go +func (a *App) moveCursorToBean(beanID string) { + items := a.list.list.Items() + for i, item := range items { + if bi, ok := item.(beanItem); ok && bi.bean.ID == beanID { + a.list.list.Select(i) + return + } + } + // Silently returns if bean not found +} +``` + +## Suggested Approaches + +1. Return a boolean indicating success/failure and handle accordingly in callers +2. Log a debug message when bean is not found +3. Add fallback behavior (e.g., clear detail pane, select first item, or skip to next history item) + +## Mitigating Factors + +- The `beansChangedMsg` handler already clears history when a bean is deleted +- In normal usage, beans in history should exist in the list +- The code doesn't crash, just shows stale data + +## Files + +- `internal/tui/tui.go` + +## Context + +Identified during code review of beans-pn6z (unified detail view refactoring). \ No newline at end of file diff --git a/.beans/beans-unjz--task-6-update-view-for-two-column-rendering-with-f.md b/.beans/beans-unjz--task-6-update-view-for-two-column-rendering-with-f.md new file mode 100644 index 00000000..d4d35519 --- /dev/null +++ b/.beans/beans-unjz--task-6-update-view-for-two-column-rendering-with-f.md @@ -0,0 +1,197 @@ +--- +# beans-unjz +title: 'Task 6: Update View() for two-column rendering with focus' +status: completed +type: task +priority: normal +created_at: 2025-12-30T16:37:12Z +updated_at: 2025-12-30T18:18:57Z +parent: beans-pn6z +--- + +## Overview + +Update the View() method to render both panes with correct focus indication, and handle narrow mode (single pane visible). + +## Files + +- Modify: `internal/tui/tui.go` (View method, renderTwoColumnView) + +## Steps + +### Step 1: Update renderTwoColumnView to pass focus state + +```go +// renderTwoColumnView renders the list and detail 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 + + // Determine focus states + listFocused := a.state == viewListFocused + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + + // Render left pane (list) with focus-dependent border + leftPane := a.list.ViewConstrained(leftWidth, contentHeight, listFocused) + + // Render right pane (detail) with focus-dependent borders + a.detail.linksFocused = linksFocused + a.detail.bodyFocused = bodyFocused + a.detail.width = rightWidth + a.detail.height = contentHeight + rightPane := a.detail.View() + + // Compose columns + columns := lipgloss.JoinHorizontal(lipgloss.Top, leftPane, rightPane) + + // App-global footer based on focused area + footer := a.renderFooter() + + return columns + "\n" + footer +} +``` + +### Step 2: Add renderFooter method + +```go +// renderFooter returns the footer help text based on current focus state +func (a *App) renderFooter() string { + var footer string + switch a.state { + case viewListFocused: + return a.list.Footer() // list handles its own status messages + case viewDetailLinksFocused: + footer = a.renderDetailLinksFooter() + case viewDetailBodyFocused: + footer = a.renderDetailBodyFooter() + default: + return "" + } + // Prepend status message for detail states + return a.prependStatusMessage(footer) +} + +func (a *App) renderDetailLinksFooter() string { + return helpKeyStyle.Render("tab") + " " + helpStyle.Render("switch") + " " + + helpKeyStyle.Render("/") + " " + helpStyle.Render("filter") + " " + + helpKeyStyle.Render("enter") + " " + helpStyle.Render("go to") + " " + + helpKeyStyle.Render("j/k") + " " + helpStyle.Render("navigate") + " " + + helpKeyStyle.Render("backspace") + " " + helpStyle.Render("back") + " " + + helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " + + helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " + + helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " + + helpKeyStyle.Render("P") + " " + helpStyle.Render("priority") + " " + + helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " + + helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " + + helpKeyStyle.Render("y") + " " + helpStyle.Render("copy id") + " " + + helpKeyStyle.Render("?") + " " + helpStyle.Render("help") + " " + + helpKeyStyle.Render("q") + " " + helpStyle.Render("quit") +} + +func (a *App) renderDetailBodyFooter() string { + var footer string + + // Only show tab switch if there are links to switch to + if len(a.detail.links) > 0 { + footer = helpKeyStyle.Render("tab") + " " + helpStyle.Render("switch") + " " + } + + footer += helpKeyStyle.Render("j/k") + " " + helpStyle.Render("scroll") + " " + + helpKeyStyle.Render("backspace") + " " + helpStyle.Render("back") + " " + + helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " + + helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " + + helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " + + helpKeyStyle.Render("P") + " " + helpStyle.Render("priority") + " " + + helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " + + helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " + + helpKeyStyle.Render("y") + " " + helpStyle.Render("copy id") + " " + + helpKeyStyle.Render("?") + " " + helpStyle.Render("help") + " " + + helpKeyStyle.Render("q") + " " + helpStyle.Render("quit") + + return footer +} + +// prependStatusMessage adds status message prefix if present +func (a *App) prependStatusMessage(footer string) string { + if a.detail.statusMessage != "" { + statusStyle := lipgloss.NewStyle().Foreground(ui.ColorSuccess).Bold(true) + return statusStyle.Render(a.detail.statusMessage) + " " + footer + } + return footer +} +``` + +### Step 3: Update View() for narrow mode + +```go +func (a *App) View() string { + switch a.state { + case viewListFocused: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } + return a.list.View() + + case viewDetailLinksFocused, viewDetailBodyFocused: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } + // Narrow mode: show only detail at full width + a.detail.linksFocused = a.state == viewDetailLinksFocused + a.detail.bodyFocused = a.state == viewDetailBodyFocused + a.detail.width = a.width + a.detail.height = a.height - 1 + return a.detail.View() + "\n" + a.renderFooter() + + case viewTagPicker: + return a.tagPicker.View() + // ... rest of picker cases unchanged, but update getBackgroundView + } + return "" +} +``` + +### Step 4: Update getBackgroundView + +```go +func (a *App) getBackgroundView() string { + switch a.previousState { + case viewListFocused: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } + return a.list.View() + case viewDetailLinksFocused, viewDetailBodyFocused: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } + return a.detail.View() + default: + return a.list.View() + } +} +``` + +### Step 5: Build and test manually + +Run: `mise build && mise beans` +Test: +- Navigate with j/k (list should have primary border) +- Press Enter (detail should get primary border, list muted) +- Press Tab (toggle between links and body focus) +- Press Backspace (return to list) +- Resize terminal below 120 cols (should show single pane) + +### Step 6: Commit + +```bash +git add internal/tui/tui.go +git commit -m "feat(tui): two-column view with focus-based borders and footers + +- Render both panes with focus-dependent border colors +- Footer changes based on which area is focused +- Narrow mode shows single pane at full width + +Refs: beans-pn6z" +``` \ 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/plans/2025-12-29-tui-filter-modal-design.md b/_spec/plans/2025-12-29-tui-filter-modal-design.md new file mode 100644 index 00000000..f97a37a8 --- /dev/null +++ b/_spec/plans/2025-12-29-tui-filter-modal-design.md @@ -0,0 +1,188 @@ +--- +date: 2025-12-29 +author: Stefan + Claude +status: approved +topic: TUI Filter Modal Design +tags: [design, tui, filtering] +--- + +# TUI Filter Modal Design + +## Overview + +Add a unified filter modal to the TUI that allows filtering beans by status, type, and tags. The modal provides quick keyboard-driven toggling with visual feedback. + +## Trigger + +Press `g` to open the filter modal from the list view. + +## Modal Layout + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Filter Beans │ +│ │ +│ STATUS TYPE TAGS │ +│ │ +│ [1] draft [m] milestone [A] backend │ +│ [2] todo ● [e] epic [B] blocked │ +│ [3] in-progress ● [f] feature ● [C] frontend ● │ +│ [4] completed [b] bug ● [D] tech-debt │ +│ [5] scrapped [t] task [E] urgent ● │ +│ │ +│ [x] reset all [enter] apply │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Visual Indicators + +- Active filters shown with `●` dot AND bold text +- Status/type labels use their configured colors (same as in list view) +- Inactive items are dimmed +- Key bindings shown in brackets and highlighted + +### Column Visibility + +- Tags column is hidden if the project has no tags +- Two-column layout (Status | Type) when no tags exist + +## Key Bindings + +| Key | Action | +|-----|--------| +| `1` | Toggle draft | +| `2` | Toggle todo | +| `3` | Toggle in-progress | +| `4` | Toggle completed | +| `5` | Toggle scrapped | +| `m` | Toggle milestone | +| `e` | Toggle epic | +| `f` | Toggle feature | +| `b` | Toggle bug | +| `t` | Toggle task | +| `A-Z` | Toggle tag (alphabetically assigned) | +| `x` | Reset all filters | +| `Enter` | Apply filters and close modal | +| `Esc` | Close without applying | + +## Filter Logic + +### Within a dimension (OR) + +Multiple selections within status, type, or tags use OR logic: + +- `[todo] + [in-progress]` → shows beans that are todo OR in-progress + +### Across dimensions (AND) + +Different dimensions combine with AND logic: + +- `[todo] + [feature]` → shows beans that are todo AND feature type + +### Empty selection + +Nothing selected in a dimension means no filtering on that dimension: + +- No statuses selected → show all statuses +- No types selected → show all types +- No tags selected → show all tags (no tag filtering) + +## Filter Bar + +A persistent filter bar appears at the bottom of the list view, above the help shortcuts. + +### Format + +``` +draft [todo] [in-progress] completed scrapped │ milestone epic [feature] [bug] task +``` + +Order is always fixed (matches modal: statuses 1-5, types m-e-f-b-t). Active items shown in bold with color. + +### Styling + +- Active filters: bold text with configured colors +- Inactive filters: dimmed text +- Filter bar always visible (all dimmed when no filters active) +- Separator `│` between status and type sections +- Tags shown after types if any are active: `│ [frontend] [urgent]` + +### Location + +``` +┌─ Beans ──────────────────────────────────────────────────────────────────────┐ +│ ID S T Title │ +│ ▌ bean-abc T F Implement user authentication │ +│ bean-def I M v2.0 Release │ +│ └─ bean-ghi T F Add dark mode support │ +│ │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ draft [todo] [in-progress] completed scrapped │ milestone epic [feature] [bug] task │ +└──────────────────────────────────────────────────────────────── g filter ? help q quit ┘ +``` + +Placed above the shortcuts bar. Should span the entire screen like the shortcut bar. + +## Tag Handling + +- Tags sorted alphabetically +- Assigned to keys A-Z (first 26 tags) +- If more than 26 tags exist, show the first 26 alphabetically +- Tags column hidden entirely if project has no tags + +## State Management + +### Filter State + +Add to `listModel`: + +```go +type listModel struct { + // ... existing fields ... + + // Filter state (replaces tagFilter string) + statusFilter []string // active status filters + typeFilter []string // active type filters + tagFilters []string // active tag filters (multi-select) +} +``` + +### Building BeanFilter + +When loading beans, construct filter from state: + +```go +filter := &model.BeanFilter{ + Status: m.statusFilter, // empty = no filtering + Type: m.typeFilter, // empty = no filtering + Tags: m.tagFilters, // empty = no filtering +} +``` + +## Implementation Notes + +### Files to Modify + +| File | Changes | +|------|---------| +| `internal/tui/list.go` | Add filter state fields, update `loadBeans()`, remove `tagFilter` | +| `internal/tui/tui.go` | Add `g` key handler, filter modal view state, remove `gt` chord handling | +| `internal/tui/filterpicker.go` | New file: filter modal model and view | +| `internal/tui/tagpicker.go` | Remove (no longer needed for filtering) | + +### Breaking Changes + +- Remove `gt` key chord for tag filtering (replaced by unified filter modal) +- Remove `tagFilter string` field from `listModel` (replaced by `tagFilters []string`) + +### Reuse Existing Infrastructure + +- `model.BeanFilter` already supports all filter types +- `ApplyFilter()` in `internal/graph/filters.go` handles the logic +- Modal patterns from `internal/tui/modal.go` for overlay rendering +- Color configuration from `config.GetBeanColors()` + +## Open Items + +- Filter persistence across TUI sessions (future enhancement) +- Keyboard shortcut shown in help overlay diff --git a/_spec/plans/2025-12-29-tui-filter-modal-implementation.md b/_spec/plans/2025-12-29-tui-filter-modal-implementation.md new file mode 100644 index 00000000..ec3238fd --- /dev/null +++ b/_spec/plans/2025-12-29-tui-filter-modal-implementation.md @@ -0,0 +1,1094 @@ +# TUI Filter Modal Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a unified filter modal to the TUI that allows filtering beans by status, type, and tags with keyboard-driven toggling. + +**Architecture:** Replace the single `tagFilter string` in `listModel` with three filter slices (`statusFilter`, `typeFilter`, `tagFilters`). Create a new `filterPickerModel` that renders a three-column modal with toggle keys. Add a persistent filter bar at the bottom of the list view. + +**Tech Stack:** Go, Bubble Tea (bubbletea), Lip Gloss (lipgloss) + +**Bean:** beans-kviq + +--- + +## Task 1: Add Filter State to listModel + +**Files:** + +- Modify: `internal/tui/list.go:117-118` + +**Step 1: Replace tagFilter with filter slices** + +Replace the single `tagFilter string` field with three slices: + +```go +// In listModel struct, replace line 117-118: +// OLD: +// tagFilter string // if set, only show beans with this tag + +// NEW: +// Active filters (empty slice = no filtering on that dimension) +statusFilter []string // active status filters +typeFilter []string // active type filters +tagFilters []string // active tag filters +``` + +**Step 2: Update setTagFilter to setFilters** + +Replace the filter methods: + +```go +// Replace setTagFilter, clearFilter, hasActiveFilter methods: + +// setFilters sets all filters at once +func (m *listModel) setFilters(statuses, types, tags []string) { + m.statusFilter = statuses + m.typeFilter = types + m.tagFilters = tags +} + +// clearFilters clears all active filters +func (m *listModel) clearFilters() { + m.statusFilter = nil + m.typeFilter = nil + m.tagFilters = nil +} + +// hasActiveFilter returns true if any filter is active +func (m *listModel) hasActiveFilter() bool { + return len(m.statusFilter) > 0 || len(m.typeFilter) > 0 || len(m.tagFilters) > 0 +} +``` + +**Step 3: Update loadBeans to use new filters** + +```go +// In loadBeans(), replace lines 170-174: +// OLD: +// var filter *model.BeanFilter +// if m.tagFilter != "" { +// filter = &model.BeanFilter{Tags: []string{m.tagFilter}} +// } + +// NEW: +var filter *model.BeanFilter +if m.hasActiveFilter() { + filter = &model.BeanFilter{ + Status: m.statusFilter, + Type: m.typeFilter, + Tags: m.tagFilters, + } +} +``` + +**Step 4: Run tests to verify no regression** + +Run: `go test ./internal/tui/...` +Expected: All existing tests pass + +**Step 5: Commit** + +```bash +git add internal/tui/list.go +git commit -m "refactor(tui): replace tagFilter with multi-filter state + +- Add statusFilter, typeFilter, tagFilters slices to listModel +- Update loadBeans() to construct BeanFilter from all dimensions +- Rename setTagFilter to setFilters, clearFilter to clearFilters + +Refs: beans-kviq" +``` + +--- + +## Task 2: Create Filter Picker Model Structure + +**Files:** + +- Create: `internal/tui/filterpicker.go` + +**Step 1: Create the file with types and messages** + +```go +package tui + +import ( + "sort" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/hmans/beans/internal/config" + "github.com/hmans/beans/internal/ui" +) + +// filterAppliedMsg is sent when filters are applied +type filterAppliedMsg struct { + statuses []string + types []string + tags []string +} + +// closeFilterPickerMsg is sent when the filter picker is cancelled +type closeFilterPickerMsg struct{} + +// openFilterPickerMsg requests opening the filter picker +type openFilterPickerMsg struct { + currentStatuses []string + currentTypes []string + currentTags []string + availableTags []string // sorted alphabetically +} + +// filterPickerModel is the model for the filter picker modal +type filterPickerModel struct { + // Current selections (toggled on/off) + selectedStatuses map[string]bool + selectedTypes map[string]bool + selectedTags map[string]bool + + // Available options + statuses []string // ordered: draft, todo, in-progress, completed, scrapped + types []string // ordered: milestone, epic, feature, bug, task + tags []string // sorted alphabetically + + // Dimensions + width int + height int +} +``` + +**Step 2: Add constructor** + +```go +// Ordered statuses for display (matches key bindings 1-5) +var filterStatusOrder = []string{"draft", "todo", "in-progress", "completed", "scrapped"} + +// Ordered types for display (matches key bindings m, e, f, b, t) +var filterTypeOrder = []string{"milestone", "epic", "feature", "bug", "task"} + +func newFilterPickerModel(msg openFilterPickerMsg, width, height int) filterPickerModel { + // Initialize selected maps from current filters + selectedStatuses := make(map[string]bool) + for _, s := range msg.currentStatuses { + selectedStatuses[s] = true + } + + selectedTypes := make(map[string]bool) + for _, t := range msg.currentTypes { + selectedTypes[t] = true + } + + selectedTags := make(map[string]bool) + for _, t := range msg.currentTags { + selectedTags[t] = true + } + + return filterPickerModel{ + selectedStatuses: selectedStatuses, + selectedTypes: selectedTypes, + selectedTags: selectedTags, + statuses: filterStatusOrder, + types: filterTypeOrder, + tags: msg.availableTags, + width: width, + height: height, + } +} +``` + +**Step 3: Add Init and basic Update** + +```go +func (m filterPickerModel) Init() tea.Cmd { + return nil +} + +func (m filterPickerModel) Update(msg tea.Msg) (filterPickerModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "esc": + return m, func() tea.Msg { return closeFilterPickerMsg{} } + + case "enter": + return m, m.applyFilters + + case "x": + // Reset all filters + m.selectedStatuses = make(map[string]bool) + m.selectedTypes = make(map[string]bool) + m.selectedTags = make(map[string]bool) + + // Status toggles (1-5) + case "1": + m.toggleStatus("draft") + case "2": + m.toggleStatus("todo") + case "3": + m.toggleStatus("in-progress") + case "4": + m.toggleStatus("completed") + case "5": + m.toggleStatus("scrapped") + + // Type toggles (m, e, f, b, t) + case "m": + m.toggleType("milestone") + case "e": + m.toggleType("epic") + case "f": + m.toggleType("feature") + case "b": + m.toggleType("bug") + case "t": + m.toggleType("task") + + default: + // Check for tag toggles (A-Z) + if len(msg.String()) == 1 { + r := rune(msg.String()[0]) + if r >= 'A' && r <= 'Z' { + idx := int(r - 'A') + if idx < len(m.tags) { + m.toggleTag(m.tags[idx]) + } + } + } + } + } + + return m, nil +} + +func (m *filterPickerModel) toggleStatus(status string) { + m.selectedStatuses[status] = !m.selectedStatuses[status] + if !m.selectedStatuses[status] { + delete(m.selectedStatuses, status) + } +} + +func (m *filterPickerModel) toggleType(typ string) { + m.selectedTypes[typ] = !m.selectedTypes[typ] + if !m.selectedTypes[typ] { + delete(m.selectedTypes, typ) + } +} + +func (m *filterPickerModel) toggleTag(tag string) { + m.selectedTags[tag] = !m.selectedTags[tag] + if !m.selectedTags[tag] { + delete(m.selectedTags, tag) + } +} + +func (m filterPickerModel) applyFilters() tea.Msg { + statuses := make([]string, 0, len(m.selectedStatuses)) + for s := range m.selectedStatuses { + statuses = append(statuses, s) + } + + types := make([]string, 0, len(m.selectedTypes)) + for t := range m.selectedTypes { + types = append(types, t) + } + + tags := make([]string, 0, len(m.selectedTags)) + for t := range m.selectedTags { + tags = append(tags, t) + } + + return filterAppliedMsg{ + statuses: statuses, + types: types, + tags: tags, + } +} +``` + +**Step 4: Run build to verify syntax** + +Run: `go build ./...` +Expected: Build succeeds + +**Step 5: Commit** + +```bash +git add internal/tui/filterpicker.go +git commit -m "feat(tui): add filter picker model structure + +- Add filterPickerModel with status/type/tag selection state +- Implement key bindings: 1-5 for status, m/e/f/b/t for type, A-Z for tags +- Add x to reset, enter to apply, esc to cancel + +Refs: beans-kviq" +``` + +--- + +## Task 3: Implement Filter Picker View + +**Files:** + +- Modify: `internal/tui/filterpicker.go` + +**Step 1: Add View method** + +```go +func (m filterPickerModel) View() string { + // Calculate modal dimensions + modalWidth := min(70, m.width-4) + + // Title + title := lipgloss.NewStyle().Bold(true).Render("Filter Beans") + + // Build columns + statusCol := m.renderStatusColumn() + typeCol := m.renderTypeColumn() + + var content string + if len(m.tags) > 0 { + tagCol := m.renderTagColumn() + content = lipgloss.JoinHorizontal(lipgloss.Top, statusCol, " ", typeCol, " ", tagCol) + } else { + content = lipgloss.JoinHorizontal(lipgloss.Top, statusCol, " ", typeCol) + } + + // Footer + footer := m.renderFooter() + + // Assemble modal + modalContent := lipgloss.JoinVertical(lipgloss.Left, + title, + "", + content, + "", + footer, + ) + + // Border style + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPrimary). + Padding(1, 2). + Width(modalWidth) + + return border.Render(modalContent) +} + +func (m filterPickerModel) renderStatusColumn() string { + header := lipgloss.NewStyle().Bold(true).Render("STATUS") + + var lines []string + lines = append(lines, header) + lines = append(lines, "") + + for i, status := range m.statuses { + key := lipgloss.NewStyle().Foreground(ui.ColorPrimary).Render(fmt.Sprintf("[%d]", i+1)) + + // Get status color from config + var statusColor string + for _, s := range config.DefaultStatuses { + if s.Name == status { + statusColor = s.Color + break + } + } + + selected := m.selectedStatuses[status] + label := m.renderLabel(status, statusColor, selected) + indicator := m.renderIndicator(selected) + + lines = append(lines, fmt.Sprintf("%s %s %s", key, label, indicator)) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m filterPickerModel) renderTypeColumn() string { + header := lipgloss.NewStyle().Bold(true).Render("TYPE") + + typeKeys := map[string]string{ + "milestone": "m", + "epic": "e", + "feature": "f", + "bug": "b", + "task": "t", + } + + var lines []string + lines = append(lines, header) + lines = append(lines, "") + + for _, typ := range m.types { + key := lipgloss.NewStyle().Foreground(ui.ColorPrimary).Render(fmt.Sprintf("[%s]", typeKeys[typ])) + + // Get type color from config + var typeColor string + for _, t := range config.DefaultTypes { + if t.Name == typ { + typeColor = t.Color + break + } + } + + selected := m.selectedTypes[typ] + label := m.renderLabel(typ, typeColor, selected) + indicator := m.renderIndicator(selected) + + lines = append(lines, fmt.Sprintf("%s %s %s", key, label, indicator)) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m filterPickerModel) renderTagColumn() string { + header := lipgloss.NewStyle().Bold(true).Render("TAGS") + + var lines []string + lines = append(lines, header) + lines = append(lines, "") + + // Show up to 26 tags (A-Z) + maxTags := min(26, len(m.tags)) + for i := 0; i < maxTags; i++ { + tag := m.tags[i] + keyChar := string(rune('A' + i)) + key := lipgloss.NewStyle().Foreground(ui.ColorPrimary).Render(fmt.Sprintf("[%s]", keyChar)) + + selected := m.selectedTags[tag] + label := m.renderLabel(tag, "", selected) + indicator := m.renderIndicator(selected) + + lines = append(lines, fmt.Sprintf("%s %s %s", key, label, indicator)) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m filterPickerModel) renderLabel(name, color string, selected bool) string { + style := lipgloss.NewStyle() + + if selected { + style = style.Bold(true) + if color != "" { + style = style.Foreground(ui.ResolveColor(color)) + } + } else { + style = style.Foreground(lipgloss.Color("#666")) + } + + // Pad to consistent width + return style.Render(fmt.Sprintf("%-12s", name)) +} + +func (m filterPickerModel) renderIndicator(selected bool) string { + if selected { + return lipgloss.NewStyle().Foreground(ui.ColorPrimary).Render("●") + } + return " " +} + +func (m filterPickerModel) renderFooter() string { + resetKey := lipgloss.NewStyle().Foreground(ui.ColorPrimary).Bold(true).Render("[x]") + resetLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#999")).Render(" reset all") + + enterKey := lipgloss.NewStyle().Foreground(ui.ColorPrimary).Bold(true).Render("[enter]") + enterLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#999")).Render(" apply") + + escKey := lipgloss.NewStyle().Foreground(ui.ColorPrimary).Bold(true).Render("[esc]") + escLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#999")).Render(" cancel") + + return resetKey + resetLabel + " " + enterKey + enterLabel + " " + escKey + escLabel +} +``` + +**Step 2: Add fmt import** + +Add `"fmt"` to the imports at the top of the file. + +**Step 3: Run build to verify** + +Run: `go build ./...` +Expected: Build succeeds + +**Step 4: Commit** + +```bash +git add internal/tui/filterpicker.go +git commit -m "feat(tui): implement filter picker view rendering + +- Add three-column layout for status/type/tags +- Show active selections with bold + color + dot indicator +- Hide tags column when no tags exist +- Render key hints and footer help + +Refs: beans-kviq" +``` + +--- + +## Task 4: Wire Filter Picker into App + +**Files:** + +- Modify: `internal/tui/tui.go` + +**Step 1: Add viewFilterPicker to viewState enum** + +```go +// In the const block around line 24-35, add: +const ( + viewList viewState = iota + viewDetail + viewTagPicker + viewParentPicker + viewStatusPicker + viewTypePicker + viewBlockingPicker + viewPriorityPicker + viewCreateModal + viewHelpOverlay + viewFilterPicker // ADD THIS LINE +) +``` + +**Step 2: Add filterPicker field to App struct** + +```go +// In App struct around line 98, add after tagPicker: +type App struct { + // ... existing fields ... + tagPicker tagPickerModel + filterPicker filterPickerModel // ADD THIS LINE + // ... rest of fields ... +} +``` + +**Step 3: Change 'g' key from chord to direct filter open** + +Replace the key chord handling (around lines 174-194): + +```go +// Replace the entire "g" chord block with direct filter open: +// OLD: if a.pendingKey == "g" { ... } and if msg.String() == "g" { a.pendingKey = "g" ... } + +// NEW: Remove the pendingKey check for "g" and handle "g" directly: +case tea.KeyMsg: + // Clear status messages on any keypress + a.list.statusMessage = "" + a.detail.statusMessage = "" + + // Handle 'g' key for filter picker (only in list view, not filtering) + if a.state == viewList && a.list.list.FilterState() != 1 { + if msg.String() == "g" { + return a, func() tea.Msg { + // Collect available tags sorted alphabetically + tags := a.collectTagsWithCounts() + sortedTags := make([]string, len(tags)) + for i, t := range tags { + sortedTags[i] = t.tag + } + sort.Strings(sortedTags) + + return openFilterPickerMsg{ + currentStatuses: a.list.statusFilter, + currentTypes: a.list.typeFilter, + currentTags: a.list.tagFilters, + availableTags: sortedTags, + } + } + } + } + + // Clear pending key on any other key press + a.pendingKey = "" + // ... rest of key handling +``` + +**Step 4: Add sort import** + +Add `"sort"` to the imports at the top of the file. + +**Step 5: Handle filter picker messages** + +Add message handlers after the existing tag picker handlers (around line 273): + +```go +// Remove or comment out the openTagPickerMsg and tagSelectedMsg handlers + +case openFilterPickerMsg: + a.filterPicker = newFilterPickerModel(msg, a.width, a.height) + a.previousState = a.state + a.state = viewFilterPicker + return a, a.filterPicker.Init() + +case filterAppliedMsg: + a.state = viewList + a.list.setFilters(msg.statuses, msg.types, msg.tags) + return a, a.list.loadBeans + +case closeFilterPickerMsg: + a.state = viewList + return a, nil +``` + +**Step 6: Forward messages to filter picker and add View case** + +In the Update switch for forwarding to views (around line 580): + +```go +case viewFilterPicker: + var cmd tea.Cmd + a.filterPicker, cmd = a.filterPicker.Update(msg) + return a, cmd +``` + +In the View method, add the filter picker case: + +```go +case viewFilterPicker: + bgView := a.renderListView() + modal := a.filterPicker.View() + return overlayModal(bgView, modal, a.width, a.height) +``` + +**Step 7: Run build and test** + +Run: `go build ./... && go test ./internal/tui/...` +Expected: Build and tests pass + +**Step 8: Commit** + +```bash +git add internal/tui/tui.go internal/tui/filterpicker.go +git commit -m "feat(tui): wire filter picker into app + +- Add viewFilterPicker state and filterPicker field +- Change 'g' key to open filter modal directly (remove gt chord) +- Handle openFilterPickerMsg, filterAppliedMsg, closeFilterPickerMsg +- Render filter picker as modal overlay + +Refs: beans-kviq" +``` + +--- + +## Task 5: Add Filter Bar to List View + +**Files:** + +- Modify: `internal/tui/list.go` + +**Step 1: Add renderFilterBar method** + +```go +// Add after the View method: + +func (m listModel) renderFilterBar() string { + // Status section + var statusParts []string + for _, status := range filterStatusOrder { + active := false + for _, s := range m.statusFilter { + if s == status { + active = true + break + } + } + statusParts = append(statusParts, m.renderFilterItem(status, "status", active)) + } + statusSection := lipgloss.JoinHorizontal(lipgloss.Left, statusParts...) + + // Type section + var typeParts []string + for _, typ := range filterTypeOrder { + active := false + for _, t := range m.typeFilter { + if t == typ { + active = true + break + } + } + typeParts = append(typeParts, m.renderFilterItem(typ, "type", active)) + } + typeSection := lipgloss.JoinHorizontal(lipgloss.Left, typeParts...) + + // Combine with separator + separator := ui.Muted.Render(" │ ") + result := statusSection + separator + typeSection + + // Add active tags if any + if len(m.tagFilters) > 0 { + var tagParts []string + for _, tag := range m.tagFilters { + tagParts = append(tagParts, m.renderFilterItem(tag, "tag", true)) + } + tagSection := lipgloss.JoinHorizontal(lipgloss.Left, tagParts...) + result += separator + tagSection + } + + return result +} + +func (m listModel) renderFilterItem(name, itemType string, active bool) string { + style := lipgloss.NewStyle() + + if active { + style = style.Bold(true) + // Get color based on type + switch itemType { + case "status": + for _, s := range config.DefaultStatuses { + if s.Name == name { + style = style.Foreground(ui.ResolveColor(s.Color)) + break + } + } + case "type": + for _, t := range config.DefaultTypes { + if t.Name == name { + style = style.Foreground(ui.ResolveColor(t.Color)) + break + } + } + case "tag": + style = style.Foreground(ui.ColorPrimary) + } + } else { + style = style.Foreground(lipgloss.Color("#555")) + } + + return style.Render(name) + " " +} +``` + +**Step 2: Add filterStatusOrder and filterTypeOrder variables** + +At the top of list.go (after imports): + +```go +// Ordered statuses for filter bar display +var filterStatusOrder = []string{"draft", "todo", "in-progress", "completed", "scrapped"} + +// Ordered types for filter bar display +var filterTypeOrder = []string{"milestone", "epic", "feature", "bug", "task"} +``` + +Note: These duplicate the ones in filterpicker.go. We could move to a shared location later, but for now keep them separate to avoid circular imports. + +**Step 3: Modify View to include filter bar** + +Find the View method and add the filter bar above the help footer. The View method renders the list with a border. We need to add the filter bar between the list content and the closing border. + +```go +// In the View method, before returning, add the filter bar: +// Find where the footer/help is rendered and add filter bar above it + +func (m listModel) View() string { + // ... existing view logic ... + + // Add filter bar + filterBar := m.renderFilterBar() + + // Combine: list + filter bar + help + // ... adjust the layout to include filterBar +} +``` + +The exact modification depends on the current View structure. Look at the current implementation and insert the filter bar appropriately. + +**Step 4: Run build and manual test** + +Run: `go build ./... && ./beans tui` +Expected: Filter bar appears at bottom of list, all items dimmed when no filters active + +**Step 5: Commit** + +```bash +git add internal/tui/list.go +git commit -m "feat(tui): add persistent filter bar to list view + +- Add renderFilterBar method showing all statuses and types +- Active filters shown bold with configured colors +- Inactive filters shown dimmed +- Active tags shown after type section + +Refs: beans-kviq" +``` + +--- + +## Task 6: Remove Old Tag Picker + +**Files:** + +- Delete: `internal/tui/tagpicker.go` +- Modify: `internal/tui/tui.go` + +**Step 1: Remove tagpicker.go file** + +```bash +rm internal/tui/tagpicker.go +``` + +**Step 2: Remove tagPicker field from App struct** + +In `tui.go`, remove the `tagPicker tagPickerModel` field from the App struct. + +**Step 3: Remove viewTagPicker from viewState enum** + +Remove `viewTagPicker` from the const block. + +**Step 4: Remove tagWithCount type if only used by tag picker** + +Check if `tagWithCount` is still needed (it's used by `collectTagsWithCounts`). If still needed, keep it. Otherwise remove. + +**Step 5: Remove openTagPickerMsg, tagSelectedMsg, clearFilterMsg types** + +These message types are no longer needed since we use the unified filter picker. + +**Step 6: Clean up any remaining references** + +Search for any remaining references to the removed types and remove them. + +**Step 7: Run build and test** + +Run: `go build ./... && go test ./internal/tui/...` +Expected: Build and tests pass + +**Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(tui): remove old tag picker + +- Delete tagpicker.go (replaced by unified filter modal) +- Remove viewTagPicker state and tagPicker field +- Remove openTagPickerMsg, tagSelectedMsg message types +- Keep collectTagsWithCounts for filter picker + +BREAKING CHANGE: 'gt' key chord no longer works, use 'g' for filter modal + +Refs: beans-kviq" +``` + +--- + +## Task 7: Update Help Overlay + +**Files:** + +- Modify: `internal/tui/help.go` + +**Step 1: Update help text to show 'g' for filter** + +Find the help overlay content and update the key bindings: + +```go +// Change: +// "g t" → "filter by tag" +// To: +// "g" → "filter" +``` + +**Step 2: Run manual test** + +Run: `./beans tui` +Press `?` to open help overlay +Expected: Shows "g" for "filter" instead of "g t" for "filter by tag" + +**Step 3: Commit** + +```bash +git add internal/tui/help.go +git commit -m "docs(tui): update help overlay for new filter key + +- Change 'g t' to 'g' for filter modal + +Refs: beans-kviq" +``` + +--- + +## Task 8: Add Integration Test + +**Files:** + +- Modify: `internal/tui/list_test.go` or create `internal/tui/filterpicker_test.go` + +**Step 1: Write test for filter state** + +```go +func TestFilterPickerModel_ToggleStatus(t *testing.T) { + msg := openFilterPickerMsg{ + currentStatuses: []string{}, + currentTypes: []string{}, + currentTags: []string{}, + availableTags: []string{"frontend", "backend"}, + } + m := newFilterPickerModel(msg, 80, 24) + + // Initially no statuses selected + if len(m.selectedStatuses) != 0 { + t.Errorf("expected 0 selected statuses, got %d", len(m.selectedStatuses)) + } + + // Toggle todo (key "2") + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + if !m.selectedStatuses["todo"] { + t.Error("expected 'todo' to be selected after pressing '2'") + } + + // Toggle again to deselect + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + if m.selectedStatuses["todo"] { + t.Error("expected 'todo' to be deselected after pressing '2' again") + } +} + +func TestFilterPickerModel_ApplyFilters(t *testing.T) { + msg := openFilterPickerMsg{ + currentStatuses: []string{"todo"}, + currentTypes: []string{"feature"}, + currentTags: []string{}, + availableTags: []string{}, + } + m := newFilterPickerModel(msg, 80, 24) + + // Verify initial state from msg + if !m.selectedStatuses["todo"] { + t.Error("expected 'todo' to be pre-selected") + } + if !m.selectedTypes["feature"] { + t.Error("expected 'feature' to be pre-selected") + } + + // Apply and check message + result := m.applyFilters() + applied, ok := result.(filterAppliedMsg) + if !ok { + t.Fatal("expected filterAppliedMsg") + } + + if len(applied.statuses) != 1 || applied.statuses[0] != "todo" { + t.Errorf("expected statuses=[todo], got %v", applied.statuses) + } + if len(applied.types) != 1 || applied.types[0] != "feature" { + t.Errorf("expected types=[feature], got %v", applied.types) + } +} + +func TestFilterPickerModel_Reset(t *testing.T) { + msg := openFilterPickerMsg{ + currentStatuses: []string{"todo", "in-progress"}, + currentTypes: []string{"bug"}, + currentTags: []string{"urgent"}, + availableTags: []string{"urgent", "backend"}, + } + m := newFilterPickerModel(msg, 80, 24) + + // Verify selections exist + if len(m.selectedStatuses) != 2 { + t.Errorf("expected 2 selected statuses, got %d", len(m.selectedStatuses)) + } + + // Press 'x' to reset + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + + if len(m.selectedStatuses) != 0 { + t.Errorf("expected 0 selected statuses after reset, got %d", len(m.selectedStatuses)) + } + if len(m.selectedTypes) != 0 { + t.Errorf("expected 0 selected types after reset, got %d", len(m.selectedTypes)) + } + if len(m.selectedTags) != 0 { + t.Errorf("expected 0 selected tags after reset, got %d", len(m.selectedTags)) + } +} +``` + +**Step 2: Run tests** + +Run: `go test ./internal/tui/... -v` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add internal/tui/filterpicker_test.go +git commit -m "test(tui): add filter picker unit tests + +- Test toggle status/type selection +- Test apply filters returns correct message +- Test reset clears all selections + +Refs: beans-kviq" +``` + +--- + +## Task 9: Manual Testing & Polish + +**Step 1: Build and run TUI** + +```bash +go build -o beans . && ./beans tui +``` + +**Step 2: Test filter workflow** + +1. Press `g` to open filter modal +2. Press `2` to toggle "todo", verify dot appears +3. Press `3` to toggle "in-progress", verify dot appears +4. Press `f` to toggle "feature", verify dot appears +5. Press `Enter` to apply +6. Verify list shows only todo/in-progress features +7. Verify filter bar shows active filters bold/colored +8. Press `g` again, verify selections are preserved +9. Press `x` to reset, press `Enter` +10. Verify all beans visible again, filter bar all dimmed + +**Step 3: Test edge cases** + +1. Open filter with no tags in project → tags column should be hidden +2. Apply filter with nothing selected → should show all beans +3. Press `Esc` to cancel → should not change filters + +**Step 4: Fix any issues found** + +Address any visual or functional issues discovered during testing. + +**Step 5: Final commit** + +```bash +git add -A +git commit -m "chore(tui): polish filter modal implementation + +Refs: beans-kviq" +``` + +--- + +## Task 10: Update Bean Status + +**Step 1: Mark bean as completed** + +```bash +beans update beans-kviq -s completed +``` + +**Step 2: Commit bean update** + +```bash +git add .beans/ +git commit -m "chore: mark beans-kviq as completed + +TUI filter modal implementation complete. + +Refs: beans-kviq" +``` 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/_spec/research/2025-12-29-tui-filtering-feature.md b/_spec/research/2025-12-29-tui-filtering-feature.md new file mode 100644 index 00000000..13bcb008 --- /dev/null +++ b/_spec/research/2025-12-29-tui-filtering-feature.md @@ -0,0 +1,228 @@ +--- +date: 2025-12-29T12:00:00+01:00 +researcher: Claude +git_commit: dc553baba6568b964f5b65e48def0afd1bbedb11 +branch: main +repository: beans +topic: "TUI Filtering Feature - Relevant Codebase Components" +tags: [research, tui, filtering, beans, feature-request] +status: complete +last_updated: 2025-12-29 +last_updated_by: Claude +--- + +# Research: TUI Filtering Feature - Relevant Codebase Components + +**Date**: 2025-12-29 +**Researcher**: Claude +**Git Commit**: dc553baba6568b964f5b65e48def0afd1bbedb11 +**Branch**: main +**Repository**: beans + +## Research Question + +Identify relevant parts of the codebase for implementing TUI filtering. Feature request: when there are many beans (especially closed/scrapped), users can't easily see relevant/open ones. The TUI should offer a way to filter beans (show only relevant/open ones, or more general filtering). + +## Summary + +The TUI currently **only supports tag filtering**, while the CLI has comprehensive filtering capabilities (status, type, priority, tags, parent, blocking, search). To implement TUI filtering, the key approach is to extend the `listModel` to use the existing `BeanFilter` infrastructure that already powers the CLI. + +**Key insight**: The filtering backend is already complete. The work is purely in the TUI layer. + +## Detailed Findings + +### Current TUI Filter State + +The TUI's `listModel` currently has a single filter field: + +**File**: `internal/tui/list.go:117-118` +```go +type listModel struct { + // ... + tagFilter string // if set, only show beans with this tag + // ... +} +``` + +This is applied when loading beans: + +**File**: `internal/tui/list.go:168-180` +```go +func (m listModel) loadBeans() tea.Msg { + var filter *model.BeanFilter + if m.tagFilter != "" { + filter = &model.BeanFilter{Tags: []string{m.tagFilter}} + } + filteredBeans, err := m.resolver.Query().Beans(context.Background(), filter) + // ... +} +``` + +### Existing Filter Infrastructure (CLI) + +The CLI already has comprehensive filtering via the `beans list` command: + +**File**: `cmd/list.go:19-40` - Filter variables +```go +var ( + listSearch string + listStatus []string + listNoStatus []string + listType []string + listNoType []string + listPriority []string + listNoPriority []string + listTag []string + listNoTag []string + listHasParent bool + listNoParent bool + listParentID string + listHasBlocking bool + listNoBlocking bool + listIsBlocked bool + listReady bool +) +``` + +**File**: `cmd/list.go:62-109` - Filter construction from CLI flags + +### GraphQL Filter Schema + +The `BeanFilter` model supports all filter types: + +**File**: `internal/graph/model/models_gen.go:6-51` +```go +type BeanFilter struct { + Search *string // Full-text search + Status []string // Include by status (OR) + ExcludeStatus []string // Exclude by status + Type []string // Include by type (OR) + ExcludeType []string // Exclude by type + Priority []string // Include by priority (OR) + ExcludePriority []string // Exclude by priority + Tags []string // Include by tags (OR) + ExcludeTags []string // Exclude by tags + HasParent *bool + NoParent *bool + ParentID *string + HasBlocking *bool + NoBlocking *bool + BlockingID *string + IsBlocked *bool +} +``` + +### Filter Application Logic + +**File**: `internal/graph/filters.go:9-80` +- `ApplyFilter()` applies all filter types sequentially to a bean slice +- Already used by GraphQL resolver for `Beans()` query +- TUI can leverage this directly by passing a populated `BeanFilter` + +### TUI Key Handling + +Current tag filter workflow: + +**File**: `internal/tui/tui.go:162-185` - Key chord handling (`gt` opens tag picker) +**File**: `internal/tui/tui.go:248-262` - Tag filter messages + +### Existing Modal Pickers + +The TUI already has modal pickers for status, type, priority that could be repurposed for filtering: + +- `internal/tui/statuspicker.go` - Status selection modal +- `internal/tui/typepicker.go` - Type selection modal +- `internal/tui/prioritypicker.go` - Priority selection modal +- `internal/tui/tagpicker.go` - Tag selection modal + +These currently mutate beans but could inspire filter picker UI. + +## Code References + +### TUI Core Files + +| File | Purpose | +|------|---------| +| `internal/tui/tui.go` | Main app, message routing, key handling | +| `internal/tui/list.go` | List view model, `loadBeans()`, current filter state | +| `internal/tui/styles.go` | TUI-specific styles | + +### Filter Infrastructure + +| File | Purpose | +|------|---------| +| `internal/graph/filters.go` | `ApplyFilter()` - core filter logic | +| `internal/graph/model/models_gen.go` | `BeanFilter` struct | +| `internal/graph/schema.graphqls:136-183` | GraphQL filter schema | +| `internal/graph/schema.resolvers.go:262-277` | `Beans()` resolver applying filters | + +### CLI Reference Implementation + +| File | Purpose | +|------|---------| +| `cmd/list.go` | CLI filter flags and construction | + +### Modal Pickers (UI patterns to follow) + +| File | Purpose | +|------|---------| +| `internal/tui/tagpicker.go` | Tag picker modal (already used for filtering) | +| `internal/tui/statuspicker.go` | Status picker modal (pattern reference) | +| `internal/tui/modal.go` | Modal rendering utilities | + +## Architecture Documentation + +### Current Data Flow + +``` +TUI list view + ↓ +loadBeans() with BeanFilter{Tags: [tagFilter]} (only tags currently) + ↓ +GraphQL resolver.Query().Beans(ctx, filter) + ↓ +ApplyFilter(beans, filter, core) + ↓ +Filtered beans returned + ↓ +BuildTree() + FlattenTree() + ↓ +Display with dimmed ancestors for context +``` + +### Key Extension Points + +1. **`listModel` struct** (`internal/tui/list.go:102-124`) + - Replace `tagFilter string` with `filter *model.BeanFilter` + - Or add individual filter fields for status, type, etc. + +2. **`loadBeans()` method** (`internal/tui/list.go:168-211`) + - Already accepts `BeanFilter`, just needs populated fields + +3. **Key bindings** (`internal/tui/tui.go:162-185`, `internal/tui/list.go:228-472`) + - Add new key chords for filter pickers (e.g., `gs` for status filter, `gp` for priority) + +4. **Filter pickers** (`internal/tui/tagpicker.go` as template) + - Create status/type/priority filter pickers (or repurpose existing) + +5. **List title** (`internal/tui/list.go:530-535`) + - Already shows tag filter, extend for other filters + +## Historical Context + +No existing specs specifically address TUI filtering. Related documents: + +- `_spec/research/2025-12-28-beans-t0tv-tui-two-column-layout.md` - Recent TUI improvements +- `_spec/plans/2025-12-28-tui-two-column-layout.md` - TUI implementation patterns + +## Related Research + +None found specifically for filtering features. + +## Open Questions + +1. **UI approach**: Should there be a single "filter panel" or individual pickers per field? +2. **Preset filters**: Should there be quick filters like "Open beans" (exclude completed/scrapped)? +3. **Filter persistence**: Should filter state persist across TUI sessions? +4. **Search integration**: Should the built-in `/` search filter visually, or should there be a "search mode" using Bleve? +5. **Filter indicators**: How to show active filters (title bar? footer? sidebar?)? diff --git a/go.mod b/go.mod index c8e81fef..7316390e 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 github.com/fsnotify/fsnotify v1.9.0 github.com/matoous/go-nanoid/v2 v2.1.0 github.com/spf13/cobra v1.10.2 @@ -28,6 +29,7 @@ require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/blevesearch/bleve_index_api v1.2.11 // indirect @@ -50,6 +52,7 @@ require ( github.com/charmbracelet/colorprofile v0.3.3 // indirect github.com/charmbracelet/x/ansi v0.11.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20251210182518-b3d4d1ed2373 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.6.1 // indirect diff --git a/go.sum b/go.sum index ff21e88e..ba4d6a40 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20251210182518-b3d4d1ed2373 h1:2NaW38gkQs5W7HFoFvCrD0VZnSLzD4126YKiAlMr7nU= github.com/charmbracelet/x/exp/slice v0.0.0-20251210182518-b3d4d1ed2373/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 h1:nCaK/2JwS/z7GoS3cIQlNYIC6MMzWLC8zkT6JkGvkn0= +github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY= diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 2fcda65e..7f8c0387 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -19,6 +19,9 @@ import ( "github.com/hmans/beans/internal/ui" ) +// borderSize is the total width/height added by a lipgloss border (1 left + 1 right, or 1 top + 1 bottom) +const borderSize = 2 + // Cached glamour renderer - initialized once per width var ( glamourRenderer *glamour.TermRenderer @@ -92,12 +95,10 @@ func (d linkDelegate) Render(w io.Writer, m list.Model, index int, listItem list // Get colors from config colors := d.cfg.GetBeanColors(link.bean.Status, link.bean.Type, link.bean.Priority) - // Calculate max title width using responsive columns - baseWidth := d.cols.ID + d.cols.Status + d.cols.Type + 12 + 4 // label + cursor + padding - if d.cols.ShowTags { - baseWidth += d.cols.Tags - } - maxTitleWidth := max(10, d.width-baseWidth-8) // 8 for border padding + // Calculate max title width - use fixed short column widths (3 chars each for type/status) + // d.width is already the inner width (minus border) + baseWidth := ui.ColWidthID + ui.ColWidthStatus + ui.ColWidthType + 12 + 4 // ID + status + type + label + cursor/padding + maxTitleWidth := max(10, d.width-baseWidth) // Use shared bean row rendering (without cursor, we handle it separately) row := ui.RenderBeanRow( @@ -114,10 +115,11 @@ func (d linkDelegate) Render(w io.Writer, m list.Model, index int, listItem list MaxTitleWidth: maxTitleWidth, ShowCursor: false, IsSelected: false, - Tags: link.bean.Tags, - ShowTags: d.cols.ShowTags, - TagsColWidth: d.cols.Tags, - MaxTags: d.cols.MaxTags, + Tags: nil, // Don't show tags in linked beans for compact display + ShowTags: false, + TagsColWidth: 0, + MaxTags: 0, + UseFullNames: false, // Short type/status for compact display }, ) @@ -135,20 +137,27 @@ type detailModel struct { ready bool links []resolvedLink // combined outgoing + incoming links linkList list.Model // list component for links (supports filtering) - linksActive bool // true = links section focused + linksFocused bool // true = links section has primary border + bodyFocused bool // true = body section has primary border cols ui.ResponsiveColumns // responsive column widths for links statusMessage string // Status message to display in footer } -func newDetailModel(b *bean.Bean, resolver *graph.Resolver, cfg *config.Config, width, height int) detailModel { +func newDetailModel(b *bean.Bean, resolver *graph.Resolver, cfg *config.Config, width, height int, linksFocused, bodyFocused bool) detailModel { m := detailModel{ - bean: b, - resolver: resolver, - config: cfg, - width: width, - height: height, - ready: true, - linksActive: false, + bean: b, + resolver: resolver, + config: cfg, + width: width, + height: height, + ready: true, + linksFocused: linksFocused, + bodyFocused: bodyFocused, + } + + if b == nil { + // Empty state - no links, empty viewport + return m } // Resolve all links @@ -171,16 +180,10 @@ func newDetailModel(b *bean.Bean, resolver *graph.Resolver, cfg *config.Config, // Initialize link list with items m.linkList = m.createLinkList() - // If there are links, select first one and focus links by default - if len(m.links) > 0 { - m.linksActive = true - } - // Calculate header height dynamically headerHeight := m.calculateHeaderHeight() - footerHeight := 2 - vpWidth := width - 4 - vpHeight := height - headerHeight - footerHeight + vpWidth := width - borderSize + vpHeight := height - headerHeight - borderSize m.viewport = viewport.New(vpWidth, vpHeight) m.viewport.SetContent(m.renderBody(vpWidth)) @@ -190,9 +193,10 @@ func newDetailModel(b *bean.Bean, resolver *graph.Resolver, cfg *config.Config, // createLinkList creates a new list.Model for the links func (m detailModel) createLinkList() list.Model { + innerWidth := m.width - borderSize delegate := linkDelegate{ cfg: m.config, - width: m.width, + width: innerWidth, cols: m.cols, } @@ -208,16 +212,12 @@ func (m detailModel) createLinkList() list.Model { } } - // Calculate list height: show all links up to 1/3 of screen height - // Add 2 for the title row and padding - maxHeight := max(3, m.height/3) - listHeight := min(len(m.links), maxHeight) + 2 + listHeight := m.linksListHeight() - l := list.New(items, delegate, m.width-8, listHeight) + l := list.New(items, delegate, m.width-2, listHeight) l.Title = "Linked Beans" l.SetShowStatusBar(false) l.SetShowHelp(false) - l.SetShowPagination(false) l.SetFilteringEnabled(true) // Style the title bar similar to the detail header title (badge style) but with different color @@ -261,16 +261,12 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) { // Update link list delegate with new dimensions m.updateLinkListDelegate() - // Update link list size: show all links up to 1/3 of screen height - // Add 2 for the title row and padding - maxHeight := max(3, msg.Height/3) - listHeight := min(len(m.links), maxHeight) + 2 - m.linkList.SetSize(msg.Width-8, listHeight) + // Update link list size + m.linkList.SetSize(msg.Width-borderSize, m.linksListHeight()) headerHeight := m.calculateHeaderHeight() - footerHeight := 2 - vpWidth := msg.Width - 4 - vpHeight := msg.Height - headerHeight - footerHeight + vpWidth := msg.Width - borderSize + vpHeight := msg.Height - headerHeight - borderSize // Ensure vpHeight doesn't go negative if vpHeight < 1 { @@ -289,27 +285,15 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) { case tea.KeyMsg: // If links list is filtering, let it handle all keys except quit - if m.linksActive && m.linkList.FilterState() == list.Filtering { + if m.linksFocused && m.linkList.FilterState() == list.Filtering { m.linkList, cmd = m.linkList.Update(msg) return m, cmd } switch msg.String() { - case "esc", "backspace": - return m, func() tea.Msg { - return backToListMsg{} - } - - case "tab": - // Toggle focus between links and body - if len(m.links) > 0 { - m.linksActive = !m.linksActive - } - return m, nil - case "enter": // Navigate to selected link - if m.linksActive { + if m.linksFocused { if item, ok := m.linkList.SelectedItem().(linkItem); ok { targetBean := item.link.bean return m, func() tea.Msg { @@ -387,7 +371,7 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) { } // Forward updates to the appropriate component - if m.linksActive && len(m.links) > 0 { + if m.linksFocused && len(m.links) > 0 { m.linkList, cmd = m.linkList.Update(msg) cmds = append(cmds, cmd) } else { @@ -402,7 +386,7 @@ func (m detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) { func (m *detailModel) updateLinkListDelegate() { delegate := linkDelegate{ cfg: m.config, - width: m.width, + width: m.width - borderSize, cols: m.cols, } m.linkList.SetDelegate(delegate) @@ -413,6 +397,10 @@ func (m detailModel) View() string { return "Loading..." } + if m.bean == nil { + return m.renderEmpty() + } + // Header (bean info only, no links) header := m.renderHeader() @@ -420,72 +408,69 @@ func (m detailModel) View() string { var linksSection string if len(m.links) > 0 { linksBorderColor := ui.ColorMuted - if m.linksActive { + if m.linksFocused { linksBorderColor = ui.ColorPrimary } linksBorder := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(linksBorderColor). - Width(m.width - 4) - linksSection = linksBorder.Render(m.linkList.View()) + "\n" + Width(m.width - borderSize) + + listView := m.linkList.View() + linksSection = linksBorder.Render(listView) + "\n" } - // Body + // Body - calculate exact height to fill remaining space bodyBorderColor := ui.ColorMuted - if !m.linksActive { + if m.bodyFocused { bodyBorderColor = ui.ColorPrimary } + + // Calculate body box height to fill remaining height + headerHeight := m.calculateHeaderHeight() + bodyBoxHeight := m.height - headerHeight + bodyContentHeight := bodyBoxHeight - borderSize + bodyBorder := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(bodyBorderColor). - Width(m.width - 4) + Width(m.width - borderSize). + Height(bodyContentHeight) body := bodyBorder.Render(m.viewport.View()) - // Footer - scrollPct := int(m.viewport.ScrollPercent() * 100) - footer := helpStyle.Render(fmt.Sprintf("%d%%", scrollPct)) + " " - if len(m.links) > 0 { - footer += helpKeyStyle.Render("tab") + " " + helpStyle.Render("switch") + " " - if m.linksActive { - footer += helpKeyStyle.Render("/") + " " + helpStyle.Render("filter") + " " - } - footer += helpKeyStyle.Render("enter") + " " + helpStyle.Render("go to") + " " - } - footer += helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " + - helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " + - helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " + - helpKeyStyle.Render("P") + " " + helpStyle.Render("priority") + " " + - helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " + - helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " + - helpKeyStyle.Render("y") + " " + helpStyle.Render("copy id") + " " + - helpKeyStyle.Render("j/k") + " " + helpStyle.Render("scroll") + " " + - helpKeyStyle.Render("?") + " " + helpStyle.Render("help") + " " + - helpKeyStyle.Render("esc") + " " + helpStyle.Render("back") + " " + - helpKeyStyle.Render("q") + " " + helpStyle.Render("quit") - - // Prepend status message if present - if m.statusMessage != "" { - statusStyle := lipgloss.NewStyle().Foreground(ui.ColorSuccess).Bold(true) - footer = statusStyle.Render(m.statusMessage) + " " + footer - } - - return header + "\n" + linksSection + body + "\n" + footer + result := header + "\n" + linksSection + body + + return result +} + +// linksListHeight returns the height to request for the bubbles list component. +// Note: bubbles list ignores this when there are few items, so calculateHeaderHeight() +// counts actual rendered lines instead. +func (m detailModel) linksListHeight() int { + if len(m.links) == 0 { + return 0 + } + maxItems := 5 + numVisible := min(len(m.links), maxItems) + height := 1 + numVisible // title + items + if len(m.links) > maxItems { + height++ // pagination dots when there are more items + } + return height } func (m detailModel) calculateHeaderHeight() int { - // Base: title line + ID/status line + borders/padding = ~6 - baseHeight := 6 + // Header box: 2 lines content + border = 4 lines + height := 2 + borderSize - // Add height for links section (separate bordered box) if len(m.links) > 0 { - // Links list height + borders (matches createLinkList calculation) - // +2 for title row and padding, +3 for borders and spacing - maxHeight := max(3, m.height/3) - listHeight := min(len(m.links), maxHeight) + 2 - baseHeight += listHeight + 3 + // Links box: count actual rendered lines (bubbles list ignores height with few items) + listView := m.linkList.View() + actualLines := strings.Count(listView, "\n") + 1 + height += actualLines + borderSize } - return baseHeight + return height } func (m detailModel) renderHeader() string { @@ -516,12 +501,11 @@ func (m detailModel) renderHeader() string { headerContent.WriteString(ui.RenderTags(m.bean.Tags)) } - // Header box style - always muted border (not focused, links section is separate) headerBox := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(ui.ColorMuted). Padding(0, 1). - Width(m.width - 4) + Width(m.width - borderSize) return headerBox.Render(headerContent.String()) } @@ -664,6 +648,15 @@ func compareBeansByStatusPriorityAndType(a, b *bean.Bean, statusNames, priorityN } +func (m detailModel) 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 detailModel) renderBody(_ int) string { if m.bean.Body == "" { return lipgloss.NewStyle(). 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..2152e96b 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, }, ) @@ -128,6 +129,8 @@ func newListModel(resolver *graph.Resolver, cfg *config.Config) listModel { delegate := itemDelegate{cfg: cfg, selectedBeans: &selectedBeans} l := list.New([]list.Item{}, delegate, 0, 0) + l.KeyMap.Quit.SetEnabled(false) // Only q quits, not escape + l.KeyMap.ForceQuit.SetEnabled(false) // Disable force quit too l.Title = "Beans" l.SetShowStatusBar(false) l.SetFilteringEnabled(true) @@ -156,11 +159,6 @@ type errMsg struct { err error } -// selectBeanMsg is sent when a bean is selected -type selectBeanMsg struct { - bean *bean.Bean -} - func (m listModel) Init() tea.Cmd { return m.loadBeans } @@ -227,6 +225,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: @@ -441,13 +443,28 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) { return clearFilterMsg{} } } + // Don't forward to bubbles list (would quit) + return m, nil } } } // 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 +496,29 @@ 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, true) + "\n" + m.Footer() +} + +// viewContent renders just the bordered list without footer. +// innerHeight is the content height inside the border (not including border lines). +// focused determines the border color (primary when focused, muted when not). +func (m listModel) viewContent(innerHeight int, focused bool) string { + borderColor := ui.ColorMuted + if focused { + borderColor = ui.ColorPrimary + } border := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(ui.ColorMuted). + BorderForeground(borderColor). 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 +577,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, focused bool) 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, focused) } diff --git a/internal/tui/modal.go b/internal/tui/modal.go index 492ba5d0..0fd93a35 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -37,7 +37,7 @@ func renderPickerModal(cfg pickerModalConfig) string { titleWidth := modalWidth - 4 beanTitle := cfg.BeanTitle if len(beanTitle) > titleWidth { - beanTitle = beanTitle[:titleWidth-3] + "..." + beanTitle = beanTitle[:titleWidth-1] + "…" } header := lipgloss.NewStyle().Bold(true).Render(beanTitle) diff --git a/internal/tui/testdata/TestDetailTabSwitchBetweenLinksAndBody.golden b/internal/tui/testdata/TestDetailTabSwitchBetweenLinksAndBody.golden new file mode 100644 index 00000000..1f7e320d --- /dev/null +++ b/internal/tui/testdata/TestDetailTabSwitchBetweenLinksAndBody.golden @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +tab switch j/k scroll esc back b blocking e edit p parent P priority s status t type y copy id ? help q quit \ No newline at end of file diff --git a/internal/tui/testdata/TestFocusTransitionBackToList.golden b/internal/tui/testdata/TestFocusTransitionBackToList.golden new file mode 100644 index 00000000..560cbb55 --- /dev/null +++ b/internal/tui/testdata/TestFocusTransitionBackToList.golden @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +space select enter view b blocking c create e edit p parent P priority s status t type y copy id / filter ? h \ No newline at end of file diff --git a/internal/tui/testdata/TestFocusTransitionListToDetail.golden b/internal/tui/testdata/TestFocusTransitionListToDetail.golden new file mode 100644 index 00000000..e4c05e2b --- /dev/null +++ b/internal/tui/testdata/TestFocusTransitionListToDetail.golden @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +tab switch / filter enter go to j/k navigate esc back b blocking e edit p parent P priority s status t type y \ No newline at end of file diff --git a/internal/tui/testdata/TestLayoutDetailViewManyLinks.golden b/internal/tui/testdata/TestLayoutDetailViewManyLinks.golden new file mode 100644 index 00000000..e4c05e2b --- /dev/null +++ b/internal/tui/testdata/TestLayoutDetailViewManyLinks.golden @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +tab switch / filter enter go to j/k navigate esc back b blocking e edit p parent P priority s status t type y \ No newline at end of file diff --git a/internal/tui/testdata/TestLayoutDetailViewWithLinks.golden b/internal/tui/testdata/TestLayoutDetailViewWithLinks.golden new file mode 100644 index 00000000..4c2470f7 --- /dev/null +++ b/internal/tui/testdata/TestLayoutDetailViewWithLinks.golden @@ -0,0 +1,79 @@ + +│ Beans ││ Parent Feature │ +│ ││ parent-01 in-progress │ +│ blocker-01 B I Blocking Bug │╰──────────────────────────────────────────────────────────────────────────────╯ +│▌parent-01 F I Parent Feat… │╭──────────────────────────────────────────────────────────────────────────────╮ + + + +│ ││ •• │ +│ │╰──────────────────────────────────────────────────────────────────────────────╯ +│ │╭──────────────────────────────────────────────────────────────────────────────╮ +│ ││ This is the parent feature. │ + + + + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +tab switch / filter enter go to j/k navigate esc back b blocking e edit p parent P priority s status t type y \ No newline at end of file diff --git a/internal/tui/testdata/TestLayoutLongTitle.golden b/internal/tui/testdata/TestLayoutLongTitle.golden new file mode 100644 index 00000000..dde66786 --- /dev/null +++ b/internal/tui/testdata/TestLayoutLongTitle.golden @@ -0,0 +1,29 @@ +[?25l[?2004h ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Beans │ +│ │ +│▌long-1234 F T This is a very long title that should be truncated when displayed in narrow … │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +space select enter view b blocking c create e edit p parent P priority s status t type y co \ No newline at end of file diff --git a/internal/tui/testdata/TestLayoutNarrowTerminal.golden b/internal/tui/testdata/TestLayoutNarrowTerminal.golden new file mode 100644 index 00000000..2fa5728b --- /dev/null +++ b/internal/tui/testdata/TestLayoutNarrowTerminal.golden @@ -0,0 +1,23 @@ +[?25l[?2004h ╭──────────────────────────────────────────────────────────────────────────────╮ +│ Beans │ +│ │ +│▌test-1234 T T Simple Task │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +space select enter view b blocking c create e edit p parent P priority s \ No newline at end of file diff --git a/internal/tui/testdata/TestLayoutResizeFromWideToNarrow.golden b/internal/tui/testdata/TestLayoutResizeFromWideToNarrow.golden new file mode 100644 index 00000000..956f2096 --- /dev/null +++ b/internal/tui/testdata/TestLayoutResizeFromWideToNarrow.golden @@ -0,0 +1,23 @@ + ╭──────────────────────────────────────────────────────────────────────────────╮ +│ Beans │ +│ │ +│▌test-1234 T T Simple Task │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +space select enter view b blocking c create e edit p parent P priority s  \ No newline at end of file diff --git a/internal/tui/testdata/TestLayoutWideTerminal.golden b/internal/tui/testdata/TestLayoutWideTerminal.golden new file mode 100644 index 00000000..5aa8d85b --- /dev/null +++ b/internal/tui/testdata/TestLayoutWideTerminal.golden @@ -0,0 +1,40 @@ +[?25l[?2004h ╭─────────────────────────────────────╮╭──────────────────────────────────────────────────────────────────────────────╮ +│ Beans ││ Blocking Bug │ +│ ││ blocker-01 in-progress │ +│▌blocker-01 B I Blocking Bug │╰──────────────────────────────────────────────────────────────────────────────╯ +│ parent-01 F I Parent Feat… │╭──────────────────────────────────────────────────────────────────────────────╮ +│ └─ child-01 T T Child Task ││ Linked Beans │ +│ ││▸ Blocking: child-01 T T Child Task │ +│ ││ │ +│ │╰──────────────────────────────────────────────────────────────────────────────╯ +│ │╭──────────────────────────────────────────────────────────────────────────────╮ +│ ││ This bug blocks the child task. │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯ +space select enter view b blocking c create e edit p parent P priority s status t type y copy id / filter ? h \ No newline at end of file diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a29f3469..36e364c0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -10,19 +10,24 @@ import ( "time" "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/hmans/beans/internal/bean" "github.com/hmans/beans/internal/beancore" "github.com/hmans/beans/internal/config" "github.com/hmans/beans/internal/graph" "github.com/hmans/beans/internal/graph/model" + "github.com/hmans/beans/internal/ui" ) // viewState represents which view is currently active type viewState int const ( - viewList viewState = iota - viewDetail + viewListFocused viewState = iota + viewDetailLinksFocused + viewDetailBodyFocused viewTagPicker viewParentPicker viewStatusPicker @@ -33,9 +38,36 @@ 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 +} + +// selectBeanMsg is sent when a bean should be selected (e.g., following a link) +type selectBeanMsg struct { + bean *bean.Bean +} + // openTagPickerMsg requests opening the tag picker type openTagPickerMsg struct{} @@ -84,7 +116,7 @@ type App struct { priorityPicker priorityPickerModel createModal createModalModel helpOverlay helpOverlayModel - history []detailModel // stack of previous detail views for back navigation + history []string // stack of previous bean IDs for back navigation core *beancore.Core resolver *graph.Resolver config *config.Config @@ -107,7 +139,7 @@ type App struct { func New(core *beancore.Core, cfg *config.Config) *App { resolver := &graph.Resolver{Core: core} return &App{ - state: viewList, + state: viewListFocused, core: core, resolver: resolver, config: cfg, @@ -120,6 +152,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,13 +166,20 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.width = msg.Width a.height = msg.Height + // Update detail dimensions + _, rightWidth := calculatePaneWidths(a.width) + // Preserve focus state when resizing + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + a.detail = newDetailModel(a.detail.bean, a.resolver, a.config, rightWidth, a.height-1, linksFocused, bodyFocused) + case tea.KeyMsg: // Clear status messages on any keypress a.list.statusMessage = "" a.detail.statusMessage = "" // Handle key chord sequences - if a.state == viewList && a.list.list.FilterState() != 1 { + if a.state == viewListFocused && a.list.list.FilterState() != list.Filtering { if a.pendingKey == "g" { a.pendingKey = "" switch msg.String() { @@ -163,35 +207,142 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c": return a, tea.Quit case "?": - // Open help overlay if not already showing it (and not in a picker/modal) - if a.state == viewList || a.state == viewDetail { + // Open help overlay from list or detail states + if a.state == viewListFocused || a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { a.previousState = a.state a.helpOverlay = newHelpOverlayModel(a.width, a.height) a.state = viewHelpOverlay return a, a.helpOverlay.Init() } case "q": - if a.state == viewDetail || a.state == viewTagPicker || a.state == viewParentPicker || a.state == viewStatusPicker || a.state == viewTypePicker || a.state == viewBlockingPicker || a.state == viewPriorityPicker || a.state == viewHelpOverlay { - return a, tea.Quit + // q always quits (except when filtering) + if a.state == viewListFocused && a.list.list.FilterState() == list.Filtering { + break // let list handle it + } + if a.state == viewDetailLinksFocused && a.detail.linkList.FilterState() == list.Filtering { + break // let detail handle it + } + return a, tea.Quit + } + + // Handle Enter from list - focus detail + if a.state == viewListFocused && msg.String() == "enter" { + if a.list.list.FilterState() != list.Filtering { + if _, ok := a.list.list.SelectedItem().(beanItem); ok { + // Focus links if bean has links, else focus body + if len(a.detail.links) > 0 { + a.detail.linksFocused = true + a.detail.bodyFocused = false + a.state = viewDetailLinksFocused + } else { + a.detail.linksFocused = false + a.detail.bodyFocused = true + a.state = viewDetailBodyFocused + } + return a, nil + } + } + } + + // Handle Tab in detail - toggle between links and body + if msg.String() == "tab" { + if a.state == viewDetailLinksFocused { + a.detail.linksFocused = false + a.detail.bodyFocused = true + a.state = viewDetailBodyFocused + return a, nil + } else if a.state == viewDetailBodyFocused && len(a.detail.links) > 0 { + a.detail.linksFocused = true + a.detail.bodyFocused = false + a.state = viewDetailLinksFocused + return a, nil } - // For list, only quit if not filtering - if a.state == viewList && a.list.list.FilterState() != 1 { - return a, tea.Quit + } + + // Handle Backspace in detail - always return to list + if msg.String() == "backspace" { + if a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { + a.detail.linksFocused = false + a.detail.bodyFocused = false + a.state = viewListFocused + a.history = nil // Clear history when explicitly returning to list + return a, nil } } + // Handle Escape in detail - navigate history or return to list + if msg.String() == "esc" { + if a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { + if len(a.history) > 0 { + // Pop from history, move cursor to that bean + prevBeanID := a.history[len(a.history)-1] + a.history = a.history[:len(a.history)-1] + // Move list cursor to that bean and trigger detail update + cmd := a.moveCursorToBean(prevBeanID) + // Stay in current detail focus state + return a, cmd + } + // No history - return to list + a.detail.linksFocused = false + a.detail.bodyFocused = false + a.state = viewListFocused + return a, nil + } + } + + case cursorChangedMsg: + _, rightWidth := calculatePaneWidths(a.width) + if msg.beanID != "" { + bean, err := a.resolver.Query().Bean(context.Background(), msg.beanID) + if err == nil && bean != nil { + // Recreate detail with current focus state + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + a.detail = newDetailModel(bean, a.resolver, a.config, rightWidth, a.height-1, linksFocused, bodyFocused) + // If we're in linksFocused but the new bean has no links, switch to bodyFocused + if a.state == viewDetailLinksFocused && len(a.detail.links) == 0 { + a.detail.linksFocused = false + a.detail.bodyFocused = true + a.state = viewDetailBodyFocused + } + } + } else { + a.detail = detailModel{} // empty detail + } + return a, nil + + case beansLoadedMsg: + // Forward to list view + a.list, cmd = a.list.Update(msg) + + // Create initial detailModel for selected bean + _, rightWidth := calculatePaneWidths(a.width) + if len(msg.items) == 0 { + // Empty list - create detail with nil bean + a.detail = newDetailModel(nil, a.resolver, a.config, rightWidth, a.height-1, false, false) + } else if item, ok := a.list.list.SelectedItem().(beanItem); ok { + // Both linksFocused and bodyFocused are false initially (focus set when Enter is pressed) + a.detail = newDetailModel(item.bean, a.resolver, a.config, rightWidth, a.height-1, false, false) + } + return a, cmd + case beansChangedMsg: // Beans changed on disk - refresh - if a.state == viewDetail { + if a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { // Try to reload the current bean via GraphQL - updatedBean, err := a.resolver.Query().Bean(context.Background(), a.detail.bean.ID) - if err != nil || updatedBean == nil { - // Bean was deleted - return to list - a.state = viewList - a.history = nil - } else { - // Recreate detail view with fresh bean data - a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height) + if a.detail.bean != nil { + updatedBean, err := a.resolver.Query().Bean(context.Background(), a.detail.bean.ID) + if err != nil || updatedBean == nil { + // Bean was deleted - return to list + a.state = viewListFocused + a.history = nil + } else { + // Recreate detail view with fresh bean data + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + _, rightWidth := calculatePaneWidths(a.width) + a.detail = newDetailModel(updatedBean, a.resolver, a.config, rightWidth, a.height-1, linksFocused, bodyFocused) + } } } // Trigger list refresh @@ -209,7 +360,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.tagPicker.Init() case tagSelectedMsg: - a.state = viewList + a.state = viewListFocused a.list.setTagFilter(msg.tag) return a, a.list.loadBeans @@ -257,10 +408,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state = a.previousState // Clear selection after batch edit clear(a.list.selectedBeans) - if a.state == viewDetail && len(msg.beanIDs) == 1 { + if (a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused) && len(msg.beanIDs) == 1 { updatedBean, _ := a.resolver.Query().Bean(context.Background(), msg.beanIDs[0]) if updatedBean != nil { - a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height) + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + _, rightWidth := calculatePaneWidths(a.width) + a.detail = newDetailModel(updatedBean, a.resolver, a.config, rightWidth, a.height-1, linksFocused, bodyFocused) } } return a, a.list.loadBeans @@ -291,10 +445,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state = a.previousState // Clear selection after batch edit clear(a.list.selectedBeans) - if a.state == viewDetail && len(msg.beanIDs) == 1 { + if (a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused) && len(msg.beanIDs) == 1 { updatedBean, _ := a.resolver.Query().Bean(context.Background(), msg.beanIDs[0]) if updatedBean != nil { - a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height) + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + _, rightWidth := calculatePaneWidths(a.width) + a.detail = newDetailModel(updatedBean, a.resolver, a.config, rightWidth, a.height-1, linksFocused, bodyFocused) } } return a, a.list.loadBeans @@ -325,10 +482,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state = a.previousState // Clear selection after batch edit clear(a.list.selectedBeans) - if a.state == viewDetail && len(msg.beanIDs) == 1 { + if (a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused) && len(msg.beanIDs) == 1 { updatedBean, _ := a.resolver.Query().Bean(context.Background(), msg.beanIDs[0]) if updatedBean != nil { - a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height) + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + _, rightWidth := calculatePaneWidths(a.width) + a.detail = newDetailModel(updatedBean, a.resolver, a.config, rightWidth, a.height-1, linksFocused, bodyFocused) } } return a, a.list.loadBeans @@ -372,10 +532,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Return to previous view and refresh a.state = a.previousState - if a.state == viewDetail { + if a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { updatedBean, _ := a.resolver.Query().Bean(context.Background(), msg.beanID) if updatedBean != nil { - a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height) + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + _, rightWidth := calculatePaneWidths(a.width) + a.detail = newDetailModel(updatedBean, a.resolver, a.config, rightWidth, a.height-1, linksFocused, bodyFocused) } } return a, a.list.loadBeans @@ -403,7 +566,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } // Return to list and open the new bean in editor - a.state = viewList + a.state = viewListFocused return a, tea.Batch( a.list.loadBeans, func() tea.Msg { @@ -466,11 +629,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state = a.previousState // Clear selection after batch edit clear(a.list.selectedBeans) - if a.state == viewDetail && len(msg.beanIDs) == 1 { + if (a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused) && len(msg.beanIDs) == 1 { // Refresh the bean to show updated parent updatedBean, _ := a.resolver.Query().Bean(context.Background(), msg.beanIDs[0]) if updatedBean != nil { - a.detail = newDetailModel(updatedBean, a.resolver, a.config, a.width, a.height) + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + _, rightWidth := calculatePaneWidths(a.width) + a.detail = newDetailModel(updatedBean, a.resolver, a.config, rightWidth, a.height-1, linksFocused, bodyFocused) } } return a, a.list.loadBeans @@ -491,43 +657,34 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Set status on current view - if a.state == viewList { + if a.state == viewListFocused { a.list.statusMessage = statusMsg - } else if a.state == viewDetail { + } else if a.state == viewDetailLinksFocused || a.state == viewDetailBodyFocused { a.detail.statusMessage = statusMsg } return a, nil case selectBeanMsg: - // Push current detail view to history if we're already viewing a bean - if a.state == viewDetail { - a.history = append(a.history, a.detail) - } - a.state = viewDetail - a.detail = newDetailModel(msg.bean, a.resolver, a.config, a.width, a.height) - return a, a.detail.Init() - - case backToListMsg: - // Pop from history if available, otherwise go to list - if len(a.history) > 0 { - a.detail = a.history[len(a.history)-1] - a.history = a.history[:len(a.history)-1] - // Stay in viewDetail state - } else { - a.state = viewList - // Force list to pick up any size changes that happened while in detail view - a.list, cmd = a.list.Update(tea.WindowSizeMsg{Width: a.width, Height: a.height}) - return a, cmd + // Push current bean ID to history before navigating + if a.detail.bean != nil { + a.history = append(a.history, a.detail.bean.ID) } - return a, nil + // Move cursor to new bean and trigger detail update + cmd := a.moveCursorToBean(msg.bean.ID) + // Switch to list focus so user can navigate the list + a.state = viewListFocused + a.detail.linksFocused = false + a.detail.bodyFocused = false + return a, cmd + } // Forward all messages to the current view switch a.state { - case viewList: + case viewListFocused: a.list, cmd = a.list.Update(msg) - case viewDetail: + case viewDetailLinksFocused, viewDetailBodyFocused: a.detail, cmd = a.detail.Update(msg) case viewTagPicker: a.tagPicker, cmd = a.tagPicker.Update(msg) @@ -550,6 +707,21 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } +// moveCursorToBean moves the list cursor to the bean with the given ID. +// Returns a command that emits cursorChangedMsg to update the detail pane. +func (a *App) moveCursorToBean(beanID string) tea.Cmd { + items := a.list.list.Items() + for i, item := range items { + if bi, ok := item.(beanItem); ok && bi.bean.ID == beanID { + a.list.list.Select(i) + return func() tea.Msg { + return cursorChangedMsg{beanID: beanID} + } + } + } + return nil +} + // collectTagsWithCounts returns all tags with their usage counts func (a *App) collectTagsWithCounts() []tagWithCount { beans, _ := a.resolver.Query().Beans(context.Background(), nil) @@ -568,13 +740,123 @@ func (a *App) collectTagsWithCounts() []tagWithCount { return tags } +// renderTwoColumnView renders the list and detail 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 + + // Determine focus states + listFocused := a.state == viewListFocused + linksFocused := a.state == viewDetailLinksFocused + bodyFocused := a.state == viewDetailBodyFocused + + // Render left pane (list) with focus-dependent border + leftPane := a.list.ViewConstrained(leftWidth, contentHeight, listFocused) + + // Render right pane (detail) with focus-dependent borders + a.detail.linksFocused = linksFocused + a.detail.bodyFocused = bodyFocused + a.detail.width = rightWidth + a.detail.height = contentHeight + rightPane := a.detail.View() + + // Compose columns + columns := lipgloss.JoinHorizontal(lipgloss.Top, leftPane, rightPane) + + // App-global footer based on focused area + footer := a.renderFooter() + + view := columns + "\n" + footer + + return view +} + +// renderFooter returns the footer help text based on current focus state +func (a *App) renderFooter() string { + var footer string + switch a.state { + case viewListFocused: + return a.list.Footer() // list handles its own status messages + case viewDetailLinksFocused: + footer = a.renderDetailLinksFooter() + case viewDetailBodyFocused: + footer = a.renderDetailBodyFooter() + default: + return "" + } + // Prepend status message for detail states + return a.prependStatusMessage(footer) +} + +func (a *App) renderDetailLinksFooter() string { + return helpKeyStyle.Render("tab") + " " + helpStyle.Render("switch") + " " + + helpKeyStyle.Render("/") + " " + helpStyle.Render("filter") + " " + + helpKeyStyle.Render("enter") + " " + helpStyle.Render("go to") + " " + + helpKeyStyle.Render("j/k") + " " + helpStyle.Render("navigate") + " " + + helpKeyStyle.Render("esc") + " " + helpStyle.Render("back") + " " + + helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " + + helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " + + helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " + + helpKeyStyle.Render("P") + " " + helpStyle.Render("priority") + " " + + helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " + + helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " + + helpKeyStyle.Render("y") + " " + helpStyle.Render("copy id") + " " + + helpKeyStyle.Render("?") + " " + helpStyle.Render("help") + " " + + helpKeyStyle.Render("q") + " " + helpStyle.Render("quit") +} + +func (a *App) renderDetailBodyFooter() string { + var footer string + + // Only show tab switch if there are links to switch to + if len(a.detail.links) > 0 { + footer = helpKeyStyle.Render("tab") + " " + helpStyle.Render("switch") + " " + } + + footer += helpKeyStyle.Render("j/k") + " " + helpStyle.Render("scroll") + " " + + helpKeyStyle.Render("esc") + " " + helpStyle.Render("back") + " " + + helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " + + helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " + + helpKeyStyle.Render("p") + " " + helpStyle.Render("parent") + " " + + helpKeyStyle.Render("P") + " " + helpStyle.Render("priority") + " " + + helpKeyStyle.Render("s") + " " + helpStyle.Render("status") + " " + + helpKeyStyle.Render("t") + " " + helpStyle.Render("type") + " " + + helpKeyStyle.Render("y") + " " + helpStyle.Render("copy id") + " " + + helpKeyStyle.Render("?") + " " + helpStyle.Render("help") + " " + + helpKeyStyle.Render("q") + " " + helpStyle.Render("quit") + + return footer +} + +// prependStatusMessage adds status message prefix if present +func (a *App) prependStatusMessage(footer string) string { + if a.detail.statusMessage != "" { + statusStyle := lipgloss.NewStyle().Foreground(ui.ColorSuccess).Bold(true) + return statusStyle.Render(a.detail.statusMessage) + " " + footer + } + return footer +} + // View renders the current view func (a *App) View() string { switch a.state { - case viewList: + case viewListFocused: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } return a.list.View() - case viewDetail: - return a.detail.View() + + case viewDetailLinksFocused, viewDetailBodyFocused: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } + // Narrow mode: show only detail at full width + a.detail.linksFocused = a.state == viewDetailLinksFocused + a.detail.bodyFocused = a.state == viewDetailBodyFocused + a.detail.width = a.width + a.detail.height = a.height - 1 + return a.detail.View() + "\n" + a.renderFooter() + case viewTagPicker: return a.tagPicker.View() case viewParentPicker: @@ -598,9 +880,15 @@ func (a *App) View() string { // getBackgroundView returns the view to show behind modal pickers func (a *App) getBackgroundView() string { switch a.previousState { - case viewList: + case viewListFocused: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } return a.list.View() - case viewDetail: + case viewDetailLinksFocused, viewDetailBodyFocused: + if a.isTwoColumnMode() { + return a.renderTwoColumnView() + } return a.detail.View() default: return a.list.View() diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 00000000..901f9d16 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,377 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/hmans/beans/internal/bean" + "github.com/hmans/beans/internal/beancore" + "github.com/hmans/beans/internal/config" +) + +// ============================================================================= +// Test Helpers +// ============================================================================= + +// setupTestApp creates an App with test beans in a temp directory +func setupTestApp(t *testing.T, beans []*bean.Bean) *App { + t.Helper() + + tmpDir := t.TempDir() + beansDir := filepath.Join(tmpDir, beancore.BeansDir) + if err := os.MkdirAll(beansDir, 0755); err != nil { + t.Fatalf("failed to create test .beans dir: %v", err) + } + + cfg := config.Default() + core := beancore.New(beansDir, cfg) + core.SetWarnWriter(nil) // suppress warnings in tests + if err := core.Load(); err != nil { + t.Fatalf("failed to load core: %v", err) + } + + // Create test beans + for _, b := range beans { + if err := core.Create(b); err != nil { + t.Fatalf("failed to create test bean %s: %v", b.ID, err) + } + } + + return New(core, cfg) +} + +// captureAndQuit waits for condition, captures output, then quits the program +func captureAndQuit(t *testing.T, tm *teatest.TestModel, condition func(string) bool) []byte { + t.Helper() + + var out []byte + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { + s := string(bts) + if condition(s) { + out = bts + return true + } + return false + }, + teatest.WithDuration(3*time.Second), + ) + + // Quit cleanly + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) + + return out +} + +// listReadyCondition returns true when the list footer is visible +func listReadyCondition(s string) bool { + return strings.Contains(s, "space") && strings.Contains(s, "select") +} + +// detailReadyCondition returns true when the detail footer is visible +func detailReadyCondition(s string) bool { + return strings.Contains(s, "esc back") +} + +// bodyFocusCondition returns true when body is focused (scroll in footer) +func bodyFocusCondition(s string) bool { + return strings.Contains(s, "scroll") +} + +// ============================================================================= +// Test Fixtures +// ============================================================================= + +// testBeanSimple returns a simple bean with no links +func testBeanSimple() *bean.Bean { + return &bean.Bean{ + ID: "test-1234", + Slug: "simple-task", + Title: "Simple Task", + Status: "todo", + Type: "task", + Body: "This is a simple task with no links.", + } +} + +// testBeanWithFewLinks returns beans with parent/child and blocking relationships +func testBeanWithFewLinks() []*bean.Bean { + return []*bean.Bean{ + { + ID: "parent-01", + Slug: "parent-feature", + Title: "Parent Feature", + Status: "in-progress", + Type: "feature", + Body: "This is the parent feature.", + Blocking: []string{"child-01"}, + }, + { + ID: "child-01", + Slug: "child-task", + Title: "Child Task", + Status: "todo", + Type: "task", + Parent: "parent-01", + Body: "This task is a child of the parent feature.", + }, + { + ID: "blocker-01", + Slug: "blocking-bug", + Title: "Blocking Bug", + Status: "in-progress", + Type: "bug", + Blocking: []string{"child-01"}, + Body: "This bug blocks the child task.", + }, + } +} + +// testBeanWithManyLinks returns an epic with 8 children to trigger pagination +func testBeanWithManyLinks() []*bean.Bean { + beans := []*bean.Bean{ + { + ID: "main-bean", + Slug: "main-epic", + Title: "Main Epic with Many Children", + Status: "in-progress", + Type: "epic", + Body: "This epic has many child tasks to test pagination in the links section.", + }, + } + + // Add 8 child beans to trigger pagination (max visible is 5) + for i := 1; i <= 8; i++ { + beans = append(beans, &bean.Bean{ + ID: fmt.Sprintf("child-%02d", i), + Slug: "child-task", + Title: "Child Task " + string(rune('A'+i-1)), + Status: "todo", + Type: "task", + Parent: "main-bean", + Body: "Child task for testing.", + }) + } + + return beans +} + +// testBeanLongTitle returns a bean with a very long title +func testBeanLongTitle() *bean.Bean { + return &bean.Bean{ + ID: "long-1234", + Slug: "very-long-title", + Title: "This is a very long title that should be truncated when displayed in narrow terminals to ensure proper layout", + Status: "todo", + Type: "feature", + Body: "Body content for the long title bean.", + } +} + +// ============================================================================= +// Layout Tests +// ============================================================================= + +// TestLayoutWideTerminal tests the two-column layout at wide terminal width +func TestLayoutWideTerminal(t *testing.T) { + beans := testBeanWithFewLinks() + app := setupTestApp(t, beans) + + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(120, 40)) + + out := captureAndQuit(t, tm, listReadyCondition) + teatest.RequireEqualOutput(t, out) +} + +// TestLayoutNarrowTerminal tests the single-column layout at narrow terminal width +func TestLayoutNarrowTerminal(t *testing.T) { + beans := []*bean.Bean{testBeanSimple()} + app := setupTestApp(t, beans) + + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(80, 24)) + + out := captureAndQuit(t, tm, listReadyCondition) + teatest.RequireEqualOutput(t, out) +} + +// TestLayoutDetailViewWithLinks tests the detail view when links section is present +func TestLayoutDetailViewWithLinks(t *testing.T) { + beans := testBeanWithFewLinks() + app := setupTestApp(t, beans) + + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(120, 40)) + + // Wait for list to load + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return listReadyCondition(string(bts)) }, + teatest.WithDuration(3*time.Second), + ) + + // Navigate to child-01 which has parent and blocked-by links + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + time.Sleep(50 * time.Millisecond) + + // Press Enter to focus detail + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + + out := captureAndQuit(t, tm, detailReadyCondition) + teatest.RequireEqualOutput(t, out) +} + +// TestLayoutDetailViewManyLinks tests layout with many links (pagination) +func TestLayoutDetailViewManyLinks(t *testing.T) { + beans := testBeanWithManyLinks() + app := setupTestApp(t, beans) + + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(120, 40)) + + // Wait for list to load + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return listReadyCondition(string(bts)) }, + teatest.WithDuration(3*time.Second), + ) + + // Press Enter to focus detail (the main-bean has 8 children) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + + out := captureAndQuit(t, tm, detailReadyCondition) + teatest.RequireEqualOutput(t, out) +} + +// TestLayoutResizeFromWideToNarrow tests layout adjustment when resizing +func TestLayoutResizeFromWideToNarrow(t *testing.T) { + beans := []*bean.Bean{testBeanSimple()} + app := setupTestApp(t, beans) + + // Start wide + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(120, 40)) + + // Wait for list to load + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return listReadyCondition(string(bts)) }, + teatest.WithDuration(3*time.Second), + ) + + // Resize to narrow + tm.Send(tea.WindowSizeMsg{Width: 80, Height: 24}) + time.Sleep(100 * time.Millisecond) + + out := captureAndQuit(t, tm, listReadyCondition) + teatest.RequireEqualOutput(t, out) +} + +// TestLayoutLongTitle tests that long titles are truncated properly +func TestLayoutLongTitle(t *testing.T) { + beans := []*bean.Bean{testBeanLongTitle()} + app := setupTestApp(t, beans) + + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(100, 30)) + + out := captureAndQuit(t, tm, listReadyCondition) + teatest.RequireEqualOutput(t, out) +} + +// ============================================================================= +// Focus Transition Tests +// ============================================================================= + +// TestFocusTransitionListToDetail tests entering detail view from list +func TestFocusTransitionListToDetail(t *testing.T) { + beans := testBeanWithFewLinks() + app := setupTestApp(t, beans) + + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(120, 40)) + + // Wait for list to load + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return listReadyCondition(string(bts)) }, + teatest.WithDuration(3*time.Second), + ) + + // Press Enter to focus detail + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + + out := captureAndQuit(t, tm, detailReadyCondition) + teatest.RequireEqualOutput(t, out) +} + +// TestFocusTransitionBackToList tests pressing backspace to return to list +func TestFocusTransitionBackToList(t *testing.T) { + beans := []*bean.Bean{testBeanSimple()} + app := setupTestApp(t, beans) + + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(120, 40)) + + // Wait for list to load + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return listReadyCondition(string(bts)) }, + teatest.WithDuration(3*time.Second), + ) + + // Press Enter to focus detail (body since no links) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + + // Wait for detail + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return detailReadyCondition(string(bts)) }, + teatest.WithDuration(3*time.Second), + ) + + // Press Backspace to return to list + tm.Send(tea.KeyMsg{Type: tea.KeyBackspace}) + + // Capture when back in list (no backspace in footer) + out := captureAndQuit(t, tm, func(s string) bool { + return listReadyCondition(s) && !strings.Contains(s, "backspace") + }) + teatest.RequireEqualOutput(t, out) +} + +// TestDetailTabSwitchBetweenLinksAndBody tests Tab key cycling between links and body +func TestDetailTabSwitchBetweenLinksAndBody(t *testing.T) { + beans := testBeanWithFewLinks() + app := setupTestApp(t, beans) + + tm := teatest.NewTestModel(t, app, + teatest.WithInitialTermSize(120, 40)) + + // Wait for list to load + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return listReadyCondition(string(bts)) }, + teatest.WithDuration(3*time.Second), + ) + + // Navigate to child-01 (has links) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + time.Sleep(50 * time.Millisecond) + + // Press Enter to focus detail (starts in links since bean has links) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + + // Wait for detail + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return detailReadyCondition(string(bts)) }, + teatest.WithDuration(3*time.Second), + ) + + // Press Tab to switch to body + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + + out := captureAndQuit(t, tm, bodyFocusCondition) + teatest.RequireEqualOutput(t, out) +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index c4213da9..4362cc5e 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) @@ -496,9 +554,9 @@ func RenderBeanRow(id, status, typeName, title string, cfg BeanRowConfig) string if maxWidth > 0 && prioritySymbol != "" { maxWidth -= 2 // Account for symbol + space } - if maxWidth > 3 && len(title) > maxWidth { - displayTitle = title[:maxWidth-3] + "..." - } else if maxWidth > 0 && maxWidth <= 3 && len(title) > maxWidth { + if maxWidth > 1 && len(title) > maxWidth { + displayTitle = title[:maxWidth-1] + "…" + } else if maxWidth > 0 && maxWidth <= 1 && len(title) > maxWidth { displayTitle = title[:maxWidth] } 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 {