detached facc709

fix: session dialog highlight doubling, row overflow, and resize flicker

- DrawLog: invalidate cached bottom rows when selection-highlight
  escapes are present so VS Code's terminal doesn't leave stale
  background colors on the previous cursor row
- session dialog: hard-clamp row text to terminal width so long
  session summaries don't soft-wrap into adjacent rows
- Resize: clear scrollback alongside screen so stale wider content
  doesn't bleed through when the terminal is narrowed
commit
facc7090604d99d817f4154f89cb2d5b969ca37e
author
patriceckhart · · 2026-05-09 20:55 UTC
parent
9f0629b
signature
unsigned
2 files · +52 -9
M internal/agent/modes/session_dialog.go +17 -8
@@ -172,7 +172,9 @@ func clampViewTop(viewTop, cursor, window, total int) int {
172172
173173 // formatSessionRowPlain returns the session row body without any ANSI
174174 // styling so the caller can wrap it in either a plain mute color or a
175-// full-row selection highlight.
175+// full-row selection highlight. The returned string is guaranteed to
176+// fit within maxWidth visible characters so the terminal never soft-
177+// wraps it into the next row.
176178 func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
177179 when := formatRelative(s.Started)
178180 summary := strings.TrimSpace(s.Title)
@@ -182,17 +184,24 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
182184 if summary == "" {
183185 summary = "(empty)"
184186 }
187+ summary = strings.ReplaceAll(summary, "\n", " ")
185188 left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ",
186189 when, s.Provider, s.Model, s.MessageCount, s.TotalCost)
187- room := maxWidth - len(left)
188- if room < 10 {
189- room = 10
190+ room := maxWidth - len([]rune(left))
191+ if room < 4 {
192+ room = 4
190193 }
191- if len(summary) > room {
192- summary = summary[:room-1] + "…"
194+ runes := []rune(summary)
195+ if len(runes) > room {
196+ summary = string(runes[:room-1]) + "…"
193197 }
194- summary = strings.ReplaceAll(summary, "\n", " ")
195- return left + summary
198+ row := left + summary
199+ // Hard clamp: ensure the full row never exceeds maxWidth.
200+ rowRunes := []rune(row)
201+ if len(rowRunes) > maxWidth {
202+ row = string(rowRunes[:maxWidth-1]) + "…"
203+ }
204+ return row
196205 }
197206
198207 func formatRelative(t time.Time) string {
@@ -172,7 +172,9 @@ func clampViewTop(viewTop, cursor, window, total int) int {
172 172
173 // formatSessionRowPlain returns the session row body without any ANSI173 // formatSessionRowPlain returns the session row body without any ANSI
174 // styling so the caller can wrap it in either a plain mute color or a174 // styling so the caller can wrap it in either a plain mute color or a
175-// full-row selection highlight.175+// full-row selection highlight. The returned string is guaranteed to
176+// fit within maxWidth visible characters so the terminal never soft-
177+// wraps it into the next row.
176 func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {178 func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
177 when := formatRelative(s.Started)179 when := formatRelative(s.Started)
178 summary := strings.TrimSpace(s.Title)180 summary := strings.TrimSpace(s.Title)
@@ -182,17 +184,24 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
182 if summary == "" {184 if summary == "" {
183 summary = "(empty)"185 summary = "(empty)"
184 }186 }
187+ summary = strings.ReplaceAll(summary, "\n", " ")
185 left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ",188 left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ",
186 when, s.Provider, s.Model, s.MessageCount, s.TotalCost)189 when, s.Provider, s.Model, s.MessageCount, s.TotalCost)
187- room := maxWidth - len(left)190+ room := maxWidth - len([]rune(left))
188- if room < 10 {191+ if room < 4 {
189- room = 10192+ room = 4
190 }193 }
191- if len(summary) > room {194+ runes := []rune(summary)
192- summary = summary[:room-1] + "…"195+ if len(runes) > room {
196+ summary = string(runes[:room-1]) + "…"
193 }197 }
194- summary = strings.ReplaceAll(summary, "\n", " ")198+ row := left + summary
195- return left + summary199+ // Hard clamp: ensure the full row never exceeds maxWidth.
200+ rowRunes := []rune(row)
201+ if len(rowRunes) > maxWidth {
202+ row = string(rowRunes[:maxWidth-1]) + "…"
203+ }
204+ return row
196 }205 }
197 206
198 func formatRelative(t time.Time) string {207 func formatRelative(t time.Time) string {
M internal/tui/render.go +35 -1
@@ -70,7 +70,10 @@ func (r *Renderer) Resize(cols, rows int) {
7070 r.logHardwareRow = 0
7171 r.logInit = false
7272 if r.out != nil {
73- _, _ = io.WriteString(r.out, SeqClearScreen)
73+ // Clear both screen and scrollback so stale content from
74+ // the old width doesn't bleed through. Move to (1,1) so
75+ // the next DrawLog/writeFull starts from a clean slate.
76+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
7477 }
7578 }
7679 }
@@ -395,6 +398,37 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
395398 w.WriteString(SeqShowCursor)
396399 }
397400
401+ // Selection-highlight workaround: VS Code's terminal (and some
402+ // others) don't reliably clear background colors when a row is
403+ // overwritten. When the current OR previous bottom block contains
404+ // selection-highlight escapes (dialogs with a cursor row), wipe
405+ // the cached bottom rows so the diff rewrites every one of them.
406+ // This mirrors the hasSelection guard in Draw().
407+ if r.logInit && len(r.logLines) > 0 {
408+ botHasHL := func(block []string) bool {
409+ for _, l := range block {
410+ if strings.Contains(l, "\x1b[48;5;") {
411+ return true
412+ }
413+ }
414+ return false
415+ }
416+ if botHasHL(bottomFrame) || botHasHL(r.logBottom) {
417+ // Invalidate only the bottom portion of the cached lines
418+ // so the chat region (terminal scrollback) is still
419+ // diffed cheaply. We do this by replacing the old bottom
420+ // rows with empty strings so every bottom row looks
421+ // "changed" to the diff below.
422+ botStart := len(r.logLines) - len(r.logBottom) - bottomMarginRows
423+ if botStart < 0 {
424+ botStart = 0
425+ }
426+ for idx := botStart; idx < len(r.logLines); idx++ {
427+ r.logLines[idx] = "\x00" // sentinel that won't match any real line
428+ }
429+ }
430+ }
431+
398432 full := !r.logInit || len(r.logLines) == 0
399433 if full {
400434 writeFull(true)
@@ -70,7 +70,10 @@ func (r *Renderer) Resize(cols, rows int) {
70 r.logHardwareRow = 070 r.logHardwareRow = 0
71 r.logInit = false71 r.logInit = false
72 if r.out != nil {72 if r.out != nil {
73- _, _ = io.WriteString(r.out, SeqClearScreen)73+ // Clear both screen and scrollback so stale content from
74+ // the old width doesn't bleed through. Move to (1,1) so
75+ // the next DrawLog/writeFull starts from a clean slate.
76+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
74 }77 }
75 }78 }
76 }79 }
@@ -395,6 +398,37 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
395 w.WriteString(SeqShowCursor)398 w.WriteString(SeqShowCursor)
396 }399 }
397 400
401+ // Selection-highlight workaround: VS Code's terminal (and some
402+ // others) don't reliably clear background colors when a row is
403+ // overwritten. When the current OR previous bottom block contains
404+ // selection-highlight escapes (dialogs with a cursor row), wipe
405+ // the cached bottom rows so the diff rewrites every one of them.
406+ // This mirrors the hasSelection guard in Draw().
407+ if r.logInit && len(r.logLines) > 0 {
408+ botHasHL := func(block []string) bool {
409+ for _, l := range block {
410+ if strings.Contains(l, "\x1b[48;5;") {
411+ return true
412+ }
413+ }
414+ return false
415+ }
416+ if botHasHL(bottomFrame) || botHasHL(r.logBottom) {
417+ // Invalidate only the bottom portion of the cached lines
418+ // so the chat region (terminal scrollback) is still
419+ // diffed cheaply. We do this by replacing the old bottom
420+ // rows with empty strings so every bottom row looks
421+ // "changed" to the diff below.
422+ botStart := len(r.logLines) - len(r.logBottom) - bottomMarginRows
423+ if botStart < 0 {
424+ botStart = 0
425+ }
426+ for idx := botStart; idx < len(r.logLines); idx++ {
427+ r.logLines[idx] = "\x00" // sentinel that won't match any real line
428+ }
429+ }
430+ }
431+
398 full := !r.logInit || len(r.logLines) == 0432 full := !r.logInit || len(r.logLines) == 0
399 if full {433 if full {
400 writeFull(true)434 writeFull(true)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close