Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions pkg/tui/components/tool/todotool/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
96 changes: 96 additions & 0 deletions pkg/tui/components/toolcommon/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions pkg/tui/components/toolcommon/truncate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading