Skip to content

Commit 769fba8

Browse files
committed
feat: make beans list responsive to terminal width
- Detect terminal width using golang.org/x/term (fallback to 80) - Use shared CalculateResponsiveColumns() from TUI - Show tags column when terminal is wide enough - Calculate title width dynamically based on available space - Fix line wrapping by accounting for priority symbol and spacing
1 parent 6027248 commit 769fba8

File tree

4 files changed

+81
-17
lines changed

4 files changed

+81
-17
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
title: Make beans list responsive to terminal width
3+
status: completed
4+
type: feature
5+
priority: normal
6+
created_at: 2025-12-13T11:56:39Z
7+
updated_at: 2025-12-13T12:12:18Z
8+
---
9+
10+
Add terminal width detection to the CLI list command so it can adjust its output (column widths, tags visibility) similar to how the TUI does it.
11+
12+
## Approach
13+
1. Detect terminal width using golang.org/x/term
14+
2. Use existing CalculateResponsiveColumns() function from internal/ui/styles.go
15+
3. Update RenderTree to accept and use responsive columns
16+
4. Pass calculated columns through to RenderBeanRow
17+
18+
## Checklist
19+
- [ ] Add terminal width detection to cmd/list.go
20+
- [ ] Update RenderTree signature to accept terminal width
21+
- [ ] Calculate responsive columns in RenderTree
22+
- [ ] Pass responsive column config to RenderBeanRow calls
23+
- [ ] Test with various terminal widths

cmd/list.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"context"
55
"fmt"
6+
"os"
67
"sort"
78

89
"github.com/hmans/beans/internal/bean"
@@ -12,6 +13,7 @@ import (
1213
"github.com/hmans/beans/internal/output"
1314
"github.com/hmans/beans/internal/ui"
1415
"github.com/spf13/cobra"
16+
"golang.org/x/term"
1517
)
1618

1719
var (
@@ -159,7 +161,13 @@ Search Syntax (--search/-S):
159161
}
160162
}
161163

162-
fmt.Print(ui.RenderTree(tree, cfg, maxIDWidth, hasTags))
164+
// Detect terminal width (default to 80 if not a terminal)
165+
termWidth := 80
166+
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
167+
termWidth = w
168+
}
169+
170+
fmt.Print(ui.RenderTree(tree, cfg, maxIDWidth, hasTags, termWidth))
163171
return nil
164172
},
165173
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
github.com/spf13/cobra v1.10.2
1919
github.com/tidwall/pretty v1.2.1
2020
github.com/vektah/gqlparser/v2 v2.5.31
21+
golang.org/x/term v0.38.0
2122
gopkg.in/yaml.v3 v3.0.1
2223
)
2324

@@ -92,7 +93,6 @@ require (
9293
golang.org/x/net v0.48.0 // indirect
9394
golang.org/x/sync v0.19.0 // indirect
9495
golang.org/x/sys v0.39.0 // indirect
95-
golang.org/x/term v0.38.0 // indirect
9696
golang.org/x/text v0.32.0 // indirect
9797
golang.org/x/tools v0.39.0 // indirect
9898
google.golang.org/protobuf v1.36.10 // indirect

internal/ui/tree.go

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ func calculateMaxDepth(nodes []*TreeNode) int {
169169
}
170170

171171
// RenderTree renders the tree as an ASCII tree with styled columns.
172-
func RenderTree(nodes []*TreeNode, cfg *config.Config, maxIDWidth int, hasTags bool) string {
172+
// termWidth is used to calculate responsive column widths.
173+
func RenderTree(nodes []*TreeNode, cfg *config.Config, maxIDWidth int, hasTags bool, termWidth int) string {
173174
var sb strings.Builder
174175

175176
// Calculate max depth to determine ID column width
@@ -184,48 +185,79 @@ func RenderTree(nodes []*TreeNode, cfg *config.Config, maxIDWidth int, hasTags b
184185
treeColWidth = maxIDWidth + maxDepth*treeIndent
185186
}
186187

188+
// Calculate responsive columns based on terminal width
189+
// Adjust for tree column width vs default ID column width
190+
adjustedWidth := termWidth - treeColWidth + ColWidthID
191+
cols := CalculateResponsiveColumns(adjustedWidth, hasTags)
192+
193+
// Calculate title width from remaining space
194+
// Account for: tree/ID col, type col, status col, priority symbol (2), space before tags (1)
195+
titleWidth := termWidth - treeColWidth - ColWidthType - ColWidthStatus - 3
196+
if cols.ShowTags {
197+
titleWidth -= cols.Tags
198+
}
199+
if titleWidth < 20 {
200+
titleWidth = 20
201+
}
202+
187203
// Header with manual padding (lipgloss Width doesn't handle styled strings well)
188204
headerCol := lipgloss.NewStyle().Foreground(ColorMuted)
189205
idHeader := headerCol.Render("ID") + strings.Repeat(" ", treeColWidth-2)
190-
typeHeader := headerCol.Render("TYPE") + strings.Repeat(" ", 12-4)
191-
statusHeader := headerCol.Render("STATUS") + strings.Repeat(" ", 14-6)
206+
typeHeader := headerCol.Render("TYPE") + strings.Repeat(" ", ColWidthType-4)
207+
statusHeader := headerCol.Render("STATUS") + strings.Repeat(" ", ColWidthStatus-6)
192208

193209
header := idHeader + typeHeader + statusHeader + headerCol.Render("TITLE")
194-
dividerWidth := treeColWidth + 12 + 14 + 50
210+
if cols.ShowTags && titleWidth > 5 {
211+
header += strings.Repeat(" ", titleWidth-5+3) + headerCol.Render("TAGS") // +3 for priority/spacing
212+
}
213+
dividerWidth := termWidth - 1 // -1 to avoid wrapping on exact terminal width
195214
sb.WriteString(header)
196215
sb.WriteString("\n")
197216
sb.WriteString(Muted.Render(strings.Repeat("─", dividerWidth)))
198217
sb.WriteString("\n")
199218

219+
// Build render config from responsive columns
220+
renderCfg := treeRenderConfig{
221+
treeColWidth: treeColWidth,
222+
titleWidth: titleWidth,
223+
cols: cols,
224+
}
225+
200226
// Render nodes (depth 0 = root level, no ancestry yet)
201-
renderNodes(&sb, nodes, 0, nil, cfg, treeColWidth, hasTags)
227+
renderNodes(&sb, nodes, 0, nil, cfg, renderCfg)
202228

203229
return sb.String()
204230
}
205231

232+
// treeRenderConfig holds computed rendering configuration for tree output
233+
type treeRenderConfig struct {
234+
treeColWidth int
235+
titleWidth int
236+
cols ResponsiveColumns
237+
}
238+
206239
// renderNodes recursively renders tree nodes with proper indentation.
207240
// depth 0 = root level (no connector), depth 1+ = nested (has connector)
208241
// ancestry tracks whether each parent level was a last child (true = last, no continuation line needed)
209-
func renderNodes(sb *strings.Builder, nodes []*TreeNode, depth int, ancestry []bool, cfg *config.Config, treeColWidth int, hasTags bool) {
242+
func renderNodes(sb *strings.Builder, nodes []*TreeNode, depth int, ancestry []bool, cfg *config.Config, renderCfg treeRenderConfig) {
210243
for i, node := range nodes {
211244
isLast := i == len(nodes)-1
212-
renderNode(sb, node, depth, isLast, ancestry, cfg, treeColWidth, hasTags)
245+
renderNode(sb, node, depth, isLast, ancestry, cfg, renderCfg)
213246
// Only add to ancestry when depth > 0 (roots have no connectors to continue)
214247
if len(node.Children) > 0 {
215248
var newAncestry []bool
216249
if depth > 0 {
217250
newAncestry = append(ancestry, isLast)
218251
}
219-
renderNodes(sb, node.Children, depth+1, newAncestry, cfg, treeColWidth, hasTags)
252+
renderNodes(sb, node.Children, depth+1, newAncestry, cfg, renderCfg)
220253
}
221254
}
222255
}
223256

224257
// renderNode renders a single tree node with tree connectors.
225-
// treeColWidth is the fixed width of the ID column (includes space for tree connectors).
226258
// depth 0 = root (no connector), depth 1+ = nested (has connector)
227259
// ancestry tracks whether each parent level was a last child (true = last, no continuation line needed)
228-
func renderNode(sb *strings.Builder, node *TreeNode, depth int, isLast bool, ancestry []bool, cfg *config.Config, treeColWidth int, hasTags bool) {
260+
func renderNode(sb *strings.Builder, node *TreeNode, depth int, isLast bool, ancestry []bool, cfg *config.Config, renderCfg treeRenderConfig) {
229261
b := node.Bean
230262

231263
// Build tree prefix from ancestry
@@ -248,21 +280,22 @@ func renderNode(sb *strings.Builder, node *TreeNode, depth int, isLast bool, anc
248280
// Get colors from config
249281
colors := cfg.GetBeanColors(b.Status, b.Type, b.Priority)
250282

251-
// Use shared RenderBeanRow function
283+
// Use shared RenderBeanRow function with responsive columns
252284
row := RenderBeanRow(b.ID, b.Status, b.Type, b.Title, BeanRowConfig{
253285
StatusColor: colors.StatusColor,
254286
TypeColor: colors.TypeColor,
255287
PriorityColor: colors.PriorityColor,
256288
Priority: b.Priority,
257289
IsArchive: colors.IsArchive,
258-
MaxTitleWidth: 50,
290+
MaxTitleWidth: renderCfg.titleWidth,
259291
ShowCursor: false,
260292
Tags: b.Tags,
261-
ShowTags: hasTags,
262-
MaxTags: 1,
293+
ShowTags: renderCfg.cols.ShowTags,
294+
TagsColWidth: renderCfg.cols.Tags,
295+
MaxTags: renderCfg.cols.MaxTags,
263296
TreePrefix: prefix,
264297
Dimmed: !node.Matched,
265-
IDColWidth: treeColWidth,
298+
IDColWidth: renderCfg.treeColWidth,
266299
})
267300

268301
sb.WriteString(row)

0 commit comments

Comments
 (0)