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 {