Skip to content

Commit 07618fa

Browse files
committed
slight rendering improvement
1 parent 475af7f commit 07618fa

2 files changed

Lines changed: 92 additions & 36 deletions

File tree

internal/ui/aichat.go

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
24582472
func 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

internal/ui/app.go

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/atotto/clipboard"
2626
tea "github.com/charmbracelet/bubbletea"
2727
"github.com/charmbracelet/lipgloss"
28+
"github.com/charmbracelet/x/ansi"
2829
"github.com/user/terminal-intelligence/internal/agentic"
2930
"github.com/user/terminal-intelligence/internal/ai"
3031
"github.com/user/terminal-intelligence/internal/bedrock"
@@ -1677,6 +1678,38 @@ func (a *App) renderEditorTitleBar() string {
16771678
//
16781679
// Returns:
16791680
// - string: Rendered UI as a string
1681+
1682+
// truncateToWidth ensures no line in the given string exceeds the specified width.
1683+
// Uses ANSI-aware truncation to correctly handle styled terminal output.
1684+
func truncateToWidth(content string, maxWidth int) string {
1685+
lines := strings.Split(content, "\n")
1686+
for i, line := range lines {
1687+
if lipgloss.Width(line) > maxWidth {
1688+
lines[i] = ansi.Truncate(line, maxWidth, "")
1689+
}
1690+
}
1691+
return strings.Join(lines, "\n")
1692+
}
1693+
1694+
// enforceWidth ensures every line in content is exactly maxWidth visual columns wide.
1695+
// Lines shorter than maxWidth are padded with spaces; longer lines are truncated.
1696+
// This makes panels truly independent — JoinHorizontal will produce exact results.
1697+
func enforceWidth(content string, maxWidth int) string {
1698+
if maxWidth <= 0 {
1699+
return content
1700+
}
1701+
lines := strings.Split(content, "\n")
1702+
for i, line := range lines {
1703+
w := lipgloss.Width(line)
1704+
if w > maxWidth {
1705+
lines[i] = ansi.Truncate(line, maxWidth, "")
1706+
} else if w < maxWidth {
1707+
lines[i] = line + strings.Repeat(" ", maxWidth-w)
1708+
}
1709+
}
1710+
return strings.Join(lines, "\n")
1711+
}
1712+
16801713
func (a *App) View() string {
16811714
if !a.ready {
16821715
return "Initializing..."
@@ -2042,6 +2075,12 @@ func (a *App) View() string {
20422075
editorContent := a.editorPane.View()
20432076
aiContent := a.aiPane.View()
20442077

2078+
// Enforce strict per-panel width by truncating each line to its allocated width.
2079+
// This prevents either panel from overflowing into the other when joined.
2080+
// Editor renders at editorPane.width - 2 (border takes 2), AI renders at aiPane.width.
2081+
editorContent = enforceWidth(editorContent, a.editorPane.width-2)
2082+
aiContent = enforceWidth(aiContent, a.aiPane.width)
2083+
20452084
// Create status bar
20462085
statusStyle := lipgloss.NewStyle().
20472086
Foreground(lipgloss.Color("15")).
@@ -2071,7 +2110,6 @@ func (a *App) View() string {
20712110
// Render full-width editor title bar
20722111
editorTitleBar := a.renderEditorTitleBar()
20732112

2074-
// Join panes side by side
20752113
mainView := lipgloss.JoinHorizontal(lipgloss.Top, editorContent, aiContent)
20762114

20772115
// Combine all sections vertically
@@ -2696,14 +2734,15 @@ func (a *App) loadChatHistory(content string) error {
26962734
for i := 0; i < len(lines); i++ {
26972735
line := lines[i]
26982736

2699-
// Check if this is a role line (starts with "user " or "assistant ")
2700-
if strings.HasPrefix(line, "user ") || strings.HasPrefix(line, "assistant ") {
2737+
// Check if this is a role line (starts with "user ", "assistant ", or "notification ")
2738+
if strings.HasPrefix(line, "user ") || strings.HasPrefix(line, "assistant ") || strings.HasPrefix(line, "notification ") {
27012739
// Save previous message if exists
27022740
if currentRole != "" && currentContent.Len() > 0 {
27032741
msg := types.ChatMessage{
2704-
Role: currentRole,
2705-
Content: strings.TrimSpace(currentContent.String()),
2706-
Timestamp: currentTimestamp,
2742+
Role: currentRole,
2743+
Content: strings.TrimSpace(currentContent.String()),
2744+
Timestamp: currentTimestamp,
2745+
IsNotification: currentRole == "notification",
27072746
}
27082747
a.aiPane.messages = append(a.aiPane.messages, msg)
27092748
}
@@ -2742,9 +2781,10 @@ func (a *App) loadChatHistory(content string) error {
27422781
// Save last message if exists
27432782
if currentRole != "" && currentContent.Len() > 0 {
27442783
msg := types.ChatMessage{
2745-
Role: currentRole,
2746-
Content: strings.TrimSpace(currentContent.String()),
2747-
Timestamp: currentTimestamp,
2784+
Role: currentRole,
2785+
Content: strings.TrimSpace(currentContent.String()),
2786+
Timestamp: currentTimestamp,
2787+
IsNotification: currentRole == "notification",
27482788
}
27492789
a.aiPane.messages = append(a.aiPane.messages, msg)
27502790
}

0 commit comments

Comments
 (0)