From 50b9e5a9effe8313aaf60b9738d10348670e7948 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Sun, 8 Feb 2026 23:20:41 +0100 Subject: [PATCH] Wrap todo descriptions at word boundaries in sidebar Replace truncation with word-aware wrapping so that todo items in the sidebar display their full description regardless of sidebar width. Long descriptions wrap onto subsequent lines, indented to align with the text after the icon prefix, and line breaks prefer word boundaries over splitting mid-word. Assisted-By: cagent Signed-off-by: Djordje Lukic --- pkg/tui/components/tool/todotool/sidebar.go | 19 +++- pkg/tui/components/toolcommon/common_test.go | 96 ++++++++++++++++++++ pkg/tui/components/toolcommon/truncate.go | 70 ++++++++++++++ 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/pkg/tui/components/tool/todotool/sidebar.go b/pkg/tui/components/tool/todotool/sidebar.go index 688f85d33..7f7f3fe03 100644 --- a/pkg/tui/components/tool/todotool/sidebar.go +++ b/pkg/tui/components/tool/todotool/sidebar.go @@ -58,12 +58,23 @@ func (c *SidebarComponent) Render() string { func (c *SidebarComponent) renderTodoLine(todo builtin.Todo) string { icon, style := renderTodoIcon(todo.Status) - // Compute prefix width dynamically (icon + space separator) prefix := icon + " " - maxDescWidth := c.width - lipgloss.Width(prefix) - description := toolcommon.TruncateText(todo.Description, maxDescWidth) + prefixWidth := lipgloss.Width(prefix) + maxDescWidth := max(1, c.width-prefixWidth) + + wrapped := toolcommon.WrapLinesWords(todo.Description, maxDescWidth) + indent := strings.Repeat(" ", prefixWidth) + + var b strings.Builder + for i, line := range wrapped { + if i == 0 { + b.WriteString(prefix + line) + } else { + b.WriteString("\n" + indent + line) + } + } - return styles.TabPrimaryStyle.Render(style.Render(prefix + description)) + return styles.TabPrimaryStyle.Render(style.Render(b.String())) } func (c *SidebarComponent) renderTab(title, content string) string { diff --git a/pkg/tui/components/toolcommon/common_test.go b/pkg/tui/components/toolcommon/common_test.go index 9a4f6fb2b..7d72718d0 100644 --- a/pkg/tui/components/toolcommon/common_test.go +++ b/pkg/tui/components/toolcommon/common_test.go @@ -378,6 +378,102 @@ func TestWrapLines(t *testing.T) { } } +func TestWrapLinesWords(t *testing.T) { + tests := []struct { + name string + text string + width int + expected []string + }{ + { + name: "fits on one line", + text: "hello world", + width: 20, + expected: []string{"hello world"}, + }, + { + name: "wraps at word boundary", + text: "hello world foo", + width: 11, + expected: []string{"hello world", "foo"}, + }, + { + name: "word exceeds width falls back to rune split", + text: "supercalifragilistic", + width: 10, + expected: []string{"supercalif", "ragilistic"}, + }, + { + name: "mixed short and long words", + text: "hi supercalifragilistic ok", + width: 10, + expected: []string{"hi", "supercalif", "ragilistic", "ok"}, + }, + { + name: "multiple lines input", + text: "hello world\nfoo bar baz", + width: 9, + expected: []string{"hello", "world", "foo bar", "baz"}, + }, + { + name: "empty string", + text: "", + width: 10, + expected: []string{""}, + }, + { + name: "zero width", + text: "hello world", + width: 0, + expected: []string{"hello world"}, + }, + { + name: "negative width", + text: "hello world", + width: -1, + expected: []string{"hello world"}, + }, + { + name: "single word exactly at width", + text: "hello", + width: 5, + expected: []string{"hello"}, + }, + { + name: "preserves empty lines", + text: "a\n\nb", + width: 10, + expected: []string{"a", "", "b"}, + }, + { + name: "each word on its own line", + text: "aa bb cc dd", + width: 3, + expected: []string{"aa", "bb", "cc", "dd"}, + }, + { + name: "unicode words", + text: "héllo wörld", + width: 6, + expected: []string{"héllo", "wörld"}, + }, + { + name: "CJK word exceeds width", + text: "你好世界 test", + width: 5, + expected: []string{"你好", "世界", "test"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := WrapLinesWords(tt.text, tt.width) + assert.Equal(t, tt.expected, result) + }) + } +} + func TestTruncateText(t *testing.T) { tests := []struct { name string diff --git a/pkg/tui/components/toolcommon/truncate.go b/pkg/tui/components/toolcommon/truncate.go index 4540de4d6..3091e6a13 100644 --- a/pkg/tui/components/toolcommon/truncate.go +++ b/pkg/tui/components/toolcommon/truncate.go @@ -132,6 +132,76 @@ func wrapTextWithIndent(text string, firstLineWidth, subsequentLineWidth int) st return result.String() } +// WrapLinesWords wraps text to fit within the given width, preferring to break +// at word boundaries (spaces). If a single word exceeds the width, it falls +// back to splitting at rune boundaries. +func WrapLinesWords(text string, width int) []string { + if width <= 0 { + return strings.Split(text, "\n") + } + + var lines []string + for inputLine := range strings.SplitSeq(text, "\n") { + if lipgloss.Width(inputLine) <= width { + lines = append(lines, inputLine) + continue + } + + words := strings.Fields(inputLine) + if len(words) == 0 { + lines = append(lines, inputLine) + continue + } + + var current strings.Builder + currentWidth := 0 + + for _, word := range words { + wWidth := lipgloss.Width(word) + + // Word itself exceeds width — split it at rune boundaries + if wWidth > width { + if currentWidth > 0 { + lines = append(lines, current.String()) + current.Reset() + currentWidth = 0 + } + runes := []rune(word) + start := 0 + for start < len(runes) { + end := takeRunesThatFit(runes, start, width) + lines = append(lines, string(runes[start:end])) + start = end + } + continue + } + + needed := wWidth + if currentWidth > 0 { + needed++ // space separator + } + + if currentWidth+needed > width { + lines = append(lines, current.String()) + current.Reset() + currentWidth = 0 + } + + if currentWidth > 0 { + current.WriteByte(' ') + currentWidth++ + } + current.WriteString(word) + currentWidth += wWidth + } + + if current.Len() > 0 { + lines = append(lines, current.String()) + } + } + return lines +} + // WrapLines wraps text to fit within the given width. // Each line that exceeds the width is split at rune boundaries. func WrapLines(text string, width int) []string {