@@ -15,6 +15,7 @@ import (
1515 "github.com/atotto/clipboard"
1616 tea "github.com/charmbracelet/bubbletea"
1717 "github.com/charmbracelet/lipgloss"
18+ "github.com/charmbracelet/x/ansi"
1819 "github.com/user/terminal-intelligence/internal/ai"
1920 "github.com/user/terminal-intelligence/internal/dirtracker"
2021 "github.com/user/terminal-intelligence/internal/docgen"
@@ -1802,7 +1803,13 @@ func (a *AIChatPane) View() string {
18021803 }
18031804
18041805 // Content width must match renderMessage() — see comment there for derivation
1806+ // Border uses Width(a.width - 4), which with border(2) + padding(2) leaves a.width - 8 for content
1807+ // We need: content + space + scrollbar = a.width - 8
1808+ // So: content = a.width - 8 - 2 = a.width - 10
18051809 contentWidth := a .width - 10
1810+ if contentWidth < 10 {
1811+ contentWidth = 10
1812+ }
18061813
18071814 // Apply scrollbar to existing lines and fill empty lines
18081815 finalLines := make ([]string , 0 , visibleLines )
@@ -1823,14 +1830,9 @@ func (a *AIChatPane) View() string {
18231830 lineWidth := lipgloss .Width (line )
18241831
18251832 // CRITICAL: Truncate line if it exceeds contentWidth
1833+ // Use lipgloss.Truncate to preserve ANSI styling
18261834 if lineWidth > contentWidth {
1827- // We use wrapTextFast to get strictly bounded slices visually
1828- // Usually this won't be hit because renderMessage already wraps,
1829- // but as a fallback it prevents layout crashes.
1830- wrapped := wrapTextFast (line , contentWidth )
1831- if len (wrapped ) > 0 {
1832- line = wrapped [0 ]
1833- }
1835+ line = truncate (line , contentWidth )
18341836 lineWidth = lipgloss .Width (line )
18351837 }
18361838
@@ -1839,9 +1841,11 @@ func (a *AIChatPane) View() string {
18391841 paddingNeed = 0
18401842 }
18411843
1844+ // Construct line: content + padding + space + scrollbar
1845+ // Total width: contentWidth + 1 + 1 = contentWidth + 2
18421846 finalLines = append (finalLines , line + strings .Repeat (" " , paddingNeed )+ " " + scrollChar )
18431847 } else {
1844- // Empty line
1848+ // Empty line: padding + space + scrollbar
18451849 finalLines = append (finalLines , strings .Repeat (" " , contentWidth )+ " " + scrollChar )
18461850 }
18471851 }
@@ -2064,6 +2068,9 @@ func (a *AIChatPane) renderViewMode() string {
20642068 totalLines := len (displayLinesSource )
20652069
20662070 // Truncate content width to prevent wrapping (account for scrollbar + border + padding)
2071+ // Border uses Width(a.width - 4), which with border(2) + padding(2) leaves a.width - 8 for content
2072+ // We need: content + space + scrollbar = a.width - 8
2073+ // So: content = a.width - 8 - 2 = a.width - 10
20672074 contentWidth := a .width - 10
20682075 if contentWidth < 10 {
20692076 contentWidth = 10
@@ -2131,16 +2138,9 @@ func (a *AIChatPane) renderViewMode() string {
21312138 runes = []rune (line )
21322139 }
21332140
2134- // Try to truncate accurately based on visual width
2141+ // Truncate accurately based on visual width using truncate helper
21352142 if lipgloss .Width (line ) > contentWidth {
2136- cutAt := contentWidth
2137- if cutAt > len (runes ) {
2138- cutAt = len (runes )
2139- }
2140- for cutAt > 0 && lipgloss .Width (string (runes [:cutAt ])) > contentWidth {
2141- cutAt --
2142- }
2143- line = string (runes [:cutAt ])
2143+ line = truncate (line , contentWidth )
21442144 }
21452145 }
21462146
@@ -2161,6 +2161,8 @@ func (a *AIChatPane) renderViewMode() string {
21612161 }
21622162 }
21632163
2164+ // Construct line: content + padding + space + scrollbar
2165+ // Total width: contentWidth + 1 + 1 = contentWidth + 2
21642166 displayLines = append (displayLines , line + strings .Repeat (" " , paddingNeed )+ " " + scrollChar )
21652167 }
21662168
@@ -2453,6 +2455,18 @@ func (a *AIChatPane) renderConfigMode() string {
24532455 Render (result )
24542456}
24552457
2458+ // truncate truncates a string (potentially with ANSI styling) to a maximum visual width.
2459+ // Uses the charmbracelet/x/ansi package for correct ANSI-aware truncation.
2460+ func truncate (s string , maxWidth int ) string {
2461+ if maxWidth < 1 {
2462+ return ""
2463+ }
2464+ if lipgloss .Width (s ) <= maxWidth {
2465+ return s
2466+ }
2467+ return ansi .Truncate (s , maxWidth , "" )
2468+ }
2469+
24562470// wrapText wraps text to fit within the specified width.
24572471// Returns a slice of lines, each fitting within the width.
24582472func wrapText (text string , width int ) []string {
@@ -2501,29 +2515,31 @@ func wrapTextFast(text string, width int) []string {
25012515 width = 1
25022516 }
25032517
2504- runes := []rune (text )
2505-
2506- // Quick path block: Check string width vs byte length
2507- if len (runes ) <= width {
2508- // Verify there are no exceptionally wide characters hiding
2509- if lipgloss .Width (text ) <= width {
2510- return []string {text }
2511- }
2518+ // Check if text fits without wrapping
2519+ if lipgloss .Width (text ) <= width {
2520+ return []string {text }
25122521 }
25132522
2523+ runes := []rune (text )
25142524 var lines []string
25152525 var currentLine []rune
25162526 currentWidth := 0
25172527
25182528 for _ , r := range runes {
25192529 w := lipgloss .Width (string (r ))
2530+ if w == 0 {
2531+ // Zero-width character (like combining marks), add it anyway
2532+ currentLine = append (currentLine , r )
2533+ continue
2534+ }
2535+
25202536 if currentWidth + w > width {
25212537 if len (currentLine ) > 0 {
25222538 lines = append (lines , string (currentLine ))
25232539 currentLine = []rune {r }
25242540 currentWidth = w
25252541 } else {
2526- // Single rune alone exceeds width
2542+ // Single rune alone exceeds width, add it anyway to avoid infinite loop
25272543 lines = append (lines , string (r ))
25282544 currentWidth = 0
25292545 }
@@ -2538,7 +2554,7 @@ func wrapTextFast(text string, width int) []string {
25382554 }
25392555
25402556 if len (lines ) == 0 {
2541- return []string {"" }
2557+ lines = []string {"" }
25422558 }
25432559
25442560 return lines
0 commit comments