fix(tui): wrap long artifact lines in viewer instead of truncating

Artifact content wider than the modal was silently cut off. Lines are now
hard-wrapped at rune boundaries to the modal content width, and scroll
clamping uses the wrapped line count so the last page stays reachable.
This commit is contained in:
2026-06-10 20:54:39 +04:00
parent cba9dd4583
commit c82a86477d
2 changed files with 49 additions and 10 deletions
+47 -9
View File
@@ -440,8 +440,9 @@ func (m Model) artifactContentBodyH() int {
return h return h
} }
// artifactContentLines splits the selected artifact's content into display lines. // artifactContentLines splits the selected artifact's content into display lines,
func (m Model) artifactContentLines() []string { // wrapping each line to the specified width.
func (m Model) artifactContentLines(w int) []string {
if m.artifactsIndex < 0 || m.artifactsIndex >= len(m.artifacts) { if m.artifactsIndex < 0 || m.artifactsIndex >= len(m.artifacts) {
return nil return nil
} }
@@ -449,12 +450,13 @@ func (m Model) artifactContentLines() []string {
if c == nil { if c == nil {
return []string{"(no content stored)"} return []string{"(no content stored)"}
} }
return strings.Split(strings.ReplaceAll(*c, "\r\n", "\n"), "\n") lines := strings.Split(strings.ReplaceAll(*c, "\r\n", "\n"), "\n")
return wrapLines(lines, w)
} }
// artifactContentMaxScroll keeps the last page of content anchored to the body bottom. // artifactContentMaxScroll keeps the last page of content anchored to the body bottom.
func (m Model) artifactContentMaxScroll() int { func (m Model) artifactContentMaxScroll(w int) int {
max := len(m.artifactContentLines()) - m.artifactContentBodyH() max := len(m.artifactContentLines(w)) - m.artifactContentBodyH()
if max < 0 { if max < 0 {
max = 0 max = 0
} }
@@ -515,11 +517,13 @@ func (m Model) artifactsModal() string {
} }
// Selected artifact content, scrollable with PgUp/PgDn. // Selected artifact content, scrollable with PgUp/PgDn.
lines := m.artifactContentLines() // Width available for content: w - 2 (left padding) - 2 (box borders) - 2 (right padding) = w - 6
contentW := w - 6
lines := m.artifactContentLines(contentW)
bodyH := m.artifactContentBodyH() bodyH := m.artifactContentBodyH()
coff := m.artifactScroll coff := m.artifactScroll
if coff > m.artifactContentMaxScroll() { if coff > m.artifactContentMaxScroll(contentW) {
coff = m.artifactContentMaxScroll() coff = m.artifactContentMaxScroll(contentW)
} }
if coff < 0 { if coff < 0 {
coff = 0 coff = 0
@@ -534,7 +538,7 @@ func (m Model) artifactsModal() string {
cend = len(lines) cend = len(lines)
} }
for i := coff; i < cend; i++ { for i := coff; i < cend; i++ {
b.WriteString(mbg(t, " "+truncate(lines[i], w-6), t.P.Fg) + "\n") b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n")
} }
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}})) b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}}))
@@ -582,3 +586,37 @@ func truncate(s string, w int) string {
} }
return string(r[:w-1]) + "…" return string(r[:w-1]) + "…"
} }
// wrapLines wraps each line in the input slice to the specified width using hard rune-boundary wrapping.
func wrapLines(lines []string, w int) []string {
if w < 1 {
w = 1
}
var result []string
for _, line := range lines {
wrapped := wrapLine(line, w)
result = append(result, wrapped...)
}
return result
}
// wrapLine wraps a single line to the specified width using hard rune-boundary wrapping.
func wrapLine(s string, w int) []string {
if w < 1 {
w = 1
}
runes := []rune(s)
if len(runes) <= w {
return []string{s}
}
var lines []string
for len(runes) > 0 {
if len(runes) <= w {
lines = append(lines, string(runes))
break
}
lines = append(lines, string(runes[:w]))
runes = runes[w:]
}
return lines
}
+2 -1
View File
@@ -401,7 +401,8 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.artifactScroll-- m.artifactScroll--
} }
case k.Type == tea.KeyPgDown: case k.Type == tea.KeyPgDown:
if m.artifactScroll < m.artifactContentMaxScroll() { contentW := m.modalWidth() - 6
if m.artifactScroll < m.artifactContentMaxScroll(contentW) {
m.artifactScroll++ m.artifactScroll++
} }
case runeIs(k, "v"): case runeIs(k, "v"):