detached c4261b5

Merge pull request #76 from bnema/fix/right-bar-rendering

fix(tui): preserve scrollback with right bar
commit
c4261b538a24fcc7085867015ee7e8dc24539e3d
author
bnema · · 2026-08-08 05:41 UTC
committer
GitHub · · 2026-08-08 05:41 UTC
merge of
020512d + 3ee53c5 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.11.5
6 files · +403 -96
M packages/agent/modes/interactive.go +0 -5
@@ -2222,11 +2222,6 @@ func (i *Interactive) redraw() {
22222222 i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
22232223
22242224 chatRows := rows - len(bottom)
2225- if rightBarActive {
2226- // DrawRightBar keeps the renderer-owned bottom margin inside the
2227- // fixed frame, so reserve that row before selecting visible chat.
2228- chatRows--
2229- }
22302225 if chatRows < 1 {
22312226 chatRows = 1
22322227 }
@@ -2222,11 +2222,6 @@ func (i *Interactive) redraw() {
2222 i.subagentActivityActive = !dashboardActive && len(subagentLines) > 02222 i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
2223 2223
2224 chatRows := rows - len(bottom)2224 chatRows := rows - len(bottom)
2225- if rightBarActive {
2226- // DrawRightBar keeps the renderer-owned bottom margin inside the
2227- // fixed frame, so reserve that row before selecting visible chat.
2228- chatRows--
2229- }
2230 if chatRows < 1 {2225 if chatRows < 1 {
2231 chatRows = 12226 chatRows = 1
2232 }2227 }
M packages/agent/modes/subagents_slash_test.go +13 -13
@@ -290,20 +290,20 @@ func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
290290 if err != nil {
291291 t.Fatal(err)
292292 }
293+ watcherDone := make(chan struct{})
294+ iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
293295 iv.runSubagents(context.Background(), []string{"wait", a.ID})
294- deadline := time.After(time.Second)
295- for {
296- iv.mu.Lock()
297- status := iv.statusOK
298- iv.mu.Unlock()
299- if status == "completed "+a.ID {
300- return
301- }
302- select {
303- case <-deadline:
304- t.Fatalf("wait did not report completion; status = %q", status)
305- case <-time.After(time.Millisecond):
306- }
296+ select {
297+ case <-watcherDone:
298+ case <-time.After(5 * time.Second):
299+ t.Fatal("timed out waiting for wait watcher to report completion")
300+ }
301+
302+ iv.mu.Lock()
303+ status := iv.statusOK
304+ iv.mu.Unlock()
305+ if status != "completed "+a.ID {
306+ t.Fatalf("wait completion status = %q; want %q", status, "completed "+a.ID)
307307 }
308308 }
309309
@@ -290,20 +290,20 @@ func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
290 if err != nil {290 if err != nil {
291 t.Fatal(err)291 t.Fatal(err)
292 }292 }
293+ watcherDone := make(chan struct{})
294+ iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
293 iv.runSubagents(context.Background(), []string{"wait", a.ID})295 iv.runSubagents(context.Background(), []string{"wait", a.ID})
294- deadline := time.After(time.Second)296+ select {
295- for {297+ case <-watcherDone:
296- iv.mu.Lock()298+ case <-time.After(5 * time.Second):
297- status := iv.statusOK299+ t.Fatal("timed out waiting for wait watcher to report completion")
298- iv.mu.Unlock()300+ }
299- if status == "completed "+a.ID {301+
300- return302+ iv.mu.Lock()
301- }303+ status := iv.statusOK
302- select {304+ iv.mu.Unlock()
303- case <-deadline:305+ if status != "completed "+a.ID {
304- t.Fatalf("wait did not report completion; status = %q", status)306+ t.Fatalf("wait completion status = %q; want %q", status, "completed "+a.ID)
305- case <-time.After(time.Millisecond):
306- }
307 }307 }
308 }308 }
309 309
M packages/tui/editor.go +15 -0
@@ -912,6 +912,21 @@ func stripANSI(s string) string {
912912 }
913913 continue
914914 }
915+ if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
916+ i += 2
917+ for i < len(runes) {
918+ if runes[i] == 0x07 {
919+ i++
920+ break
921+ }
922+ if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '\\' {
923+ i += 2
924+ break
925+ }
926+ i++
927+ }
928+ continue
929+ }
915930 out = append(out, runes[i])
916931 i++
917932 }
@@ -912,6 +912,21 @@ func stripANSI(s string) string {
912 }912 }
913 continue913 continue
914 }914 }
915+ if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
916+ i += 2
917+ for i < len(runes) {
918+ if runes[i] == 0x07 {
919+ i++
920+ break
921+ }
922+ if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '\\' {
923+ i += 2
924+ break
925+ }
926+ i++
927+ }
928+ continue
929+ }
915 out = append(out, runes[i])930 out = append(out, runes[i])
916 i++931 i++
917 }932 }
M packages/tui/render.go +131 -30
@@ -42,16 +42,24 @@ type Renderer struct {
4242 logLines []string
4343 // Raw logical rows let DrawLog reject an unchanged frame before
4444 // truncating and repainting every transcript row.
45- logRawChat []string
46- logRawBottom []string
47- logRawCols int
48- logRawRows int
49- logRawBackground string
50- logRawHasThemeBG bool
51- logViewportTop int
52- logHardwareRow int
53- logInit bool
54- logNeedsFullRepaint bool
45+ logRawChat []string
46+ logRawBottom []string
47+ logRawCols int
48+ logRawRows int
49+ logRawContentCols int
50+ logRawBackground string
51+ logRawHasThemeBG bool
52+ logViewportTop int
53+ logHardwareRow int
54+ logInit bool
55+
56+ // The right bar is a viewport overlay on top of the main-screen flow
57+ // renderer. Keeping its cache separate prevents persistent chrome from
58+ // replacing the transcript's logical buffer and native scrollback.
59+ rightBarActive bool
60+ rightBarLines []string
61+ rightBarMainCols int
62+ rightBarDimmed bool
5563
5664 // keepScrollback is true when we must NOT emit \x1b[3J
5765 // (erase-in-display 3, "clear scrollback").
@@ -130,6 +138,9 @@ func (r *Renderer) Resize(cols, rows int) {
130138 r.logViewportTop = 0
131139 r.logHardwareRow = 0
132140 r.logInit = false
141+ r.rightBarActive = false
142+ r.rightBarLines = nil
143+ r.rightBarMainCols = 0
133144 if r.out != nil {
134145 if r.keepScrollback {
135146 // A resize is a discrete user action, like Ctrl+L: the
@@ -166,6 +177,9 @@ func (r *Renderer) Clear() {
166177 r.logViewportTop = 0
167178 r.logHardwareRow = 0
168179 r.logInit = false
180+ r.rightBarActive = false
181+ r.rightBarLines = nil
182+ r.rightBarMainCols = 0
169183 if r.keepScrollback {
170184 // On VS Code's xterm.js the transcript is taller than the
171185 // viewport, so an in-place clear (home + erase-to-end) only
@@ -264,6 +278,7 @@ func truncateToWidth(s string, cols int) string {
264278 var out strings.Builder
265279 out.Grow(len(s))
266280 seen := 0
281+ osc8Active := false
267282 runes := []rune(s)
268283 for i := 0; i < len(runes); {
269284 r := runes[i]
@@ -282,8 +297,41 @@ func truncateToWidth(s string, cols int) string {
282297 }
283298 continue
284299 }
300+ // OSC string escape (BEL or ST terminated): zero-width and atomic.
301+ if r == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
302+ start := i
303+ i += 2
304+ for i < len(runes) {
305+ c := runes[i]
306+ i++
307+ if c == 0x07 {
308+ break
309+ }
310+ if c == 0x1b && i < len(runes) && runes[i] == '\\' {
311+ i++
312+ break
313+ }
314+ }
315+ osc8Closer := start+5 < i && runes[start+2] == '8' && runes[start+3] == ';' && runes[start+4] == ';' &&
316+ ((runes[start+5] == 0x07 && i == start+6) ||
317+ (start+6 < i && runes[start+5] == 0x1b && runes[start+6] == '\\' && i == start+7))
318+ if seen < cols || osc8Closer {
319+ for _, c := range runes[start:i] {
320+ out.WriteRune(c)
321+ }
322+ if runes[start+2] == '8' {
323+ osc8Active = !osc8Closer
324+ }
325+ }
326+ continue
327+ }
285328 rw := runewidthRune(r)
286329 if seen+rw > cols {
330+ if osc8Active {
331+ // The remaining text is omitted, so close a hyperlink
332+ // whose opener was already emitted before the boundary.
333+ out.WriteString("\x1b]8;;\x07")
334+ }
287335 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
288336 // so background colors and cleanup sequences survive.
289337 for i < len(runes) {
@@ -299,6 +347,31 @@ func truncateToWidth(s string, cols int) string {
299347 break
300348 }
301349 }
350+ } else if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
351+ // Do not append a new OSC sequence after the visible
352+ // boundary: an OSC 8 opener would leak hyperlink state
353+ // into the next terminal row. A matching OSC 8 closer is
354+ // retained so a link opened before the boundary is closed.
355+ keep := i+5 < len(runes) && runes[i+2] == '8' && runes[i+3] == ';' && runes[i+4] == ';' &&
356+ (runes[i+5] == 0x07 || (i+6 < len(runes) && runes[i+5] == 0x1b && runes[i+6] == '\\'))
357+ start := i
358+ i += 2
359+ for i < len(runes) {
360+ c := runes[i]
361+ i++
362+ if c == 0x07 {
363+ break
364+ }
365+ if c == 0x1b && i < len(runes) && runes[i] == '\\' {
366+ i++
367+ break
368+ }
369+ }
370+ if keep {
371+ for _, c := range runes[start:i] {
372+ out.WriteRune(c)
373+ }
374+ }
302375 } else {
303376 break
304377 }
@@ -432,26 +505,34 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
432505 //
433506 // cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
434507 func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
508+ if r.rightBarActive {
509+ r.clearRightBarOverlay()
510+ }
511+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols)
512+}
513+
514+// drawLog is DrawLog's owning flow implementation. contentCols may reserve
515+// terminal columns for a separately composited viewport overlay.
516+func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
435517 if r.cols == 0 || r.rows == 0 {
436- return
518+ return false
519+ }
520+ if contentCols <= 0 || contentCols > r.cols {
521+ contentCols = r.cols
437522 }
438523 if len(bottom) == 0 {
439524 bottom = []string{""}
440525 }
441- if r.logInit && len(r.logLines) > 0 &&
442- cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
443- r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle &&
444- r.logRawHasThemeBG == (r.theme.Background != nil) &&
445- sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
446- return
526+ if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) {
527+ return false
447528 }
448529 chatFrame := make([]string, len(chat))
449530 for i, line := range chat {
450- chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
531+ chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
451532 }
452533 bottomFrame := make([]string, len(bottom))
453534 for i, line := range bottom {
454- bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
535+ bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
455536 }
456537
457538 // Always reserve one real row below the editor/status band. This is
@@ -463,7 +544,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
463544 lines = append(lines, chatFrame...)
464545 lines = append(lines, bottomFrame...)
465546 for range bottomMarginRows {
466- lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
547+ lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
467548 }
468549 // In main-screen flow mode zut normally emits only its logical
469550 // content rows and leaves the rest of the terminal viewport alone.
@@ -473,7 +554,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
473554 // default transparent case.
474555 if r.theme.Background != nil {
475556 for len(lines) < r.rows {
476- lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
557+ lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
477558 }
478559 }
479560 if len(lines) == 0 {
@@ -495,8 +576,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
495576 // that never blinks, because we keep "showing" it before the
496577 // terminal can blink it off. Bailing out here lets the OS run
497578 // its blink cycle.
498- if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
499- return
579+ if r.logInit && r.logRawContentCols == contentCols &&
580+ cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
581+ return false
500582 }
501583
502584 var w strings.Builder
@@ -590,8 +672,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
590672 }
591673 moveToLogicalRow(cursorTargetRow)
592674 w.WriteString("\r")
593- if cursorCol > 0 {
594- w.WriteString("\x1b[" + itoa(cursorCol) + "C")
675+ physicalCursorCol := cursorCol
676+ if physicalCursorCol < 0 {
677+ physicalCursorCol = 0
678+ }
679+ if physicalCursorCol >= contentCols {
680+ physicalCursorCol = contentCols - 1
681+ }
682+ if physicalCursorCol > 0 {
683+ w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C")
595684 }
596685 w.WriteString(SeqShowCursor)
597686 }
@@ -605,12 +694,10 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
605694 wasInitialized := r.logInit
606695 full := !wasInitialized || len(r.logLines) == 0
607696 if full {
608- // Returning from the fixed right-bar frame needs a full viewport
609- // repaint, but it must not discard terminal scrollback merely because
610- // the renderer's flow cache is intentionally empty.
611- writeFull(true, !wasInitialized && !r.logNeedsFullRepaint)
697+ // A cache invalidation may require a full viewport repaint after flow
698+ // has started, but only the initial paint may discard old scrollback.
699+ writeFull(true, !wasInitialized)
612700 r.logInit = true
613- r.logNeedsFullRepaint = false
614701 } else {
615702 firstChanged := -1
616703 lastChanged := -1
@@ -827,10 +914,24 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
827914 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
828915 r.logRawCols = r.cols
829916 r.logRawRows = r.rows
917+ r.logRawContentCols = contentCols
830918 r.logRawBackground = r.backgroundStyle
831919 r.logRawHasThemeBG = r.theme.Background != nil
832920 r.cursorRow = cursorBottomRow
833921 r.cursorCol = cursorCol
922+ return true
923+}
924+
925+func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
926+ return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
927+ r.logRawContentUnchanged(chat, bottom, contentCols)
928+}
929+
930+func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool {
931+ return r.logInit && len(r.logLines) > 0 &&
932+ r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols &&
933+ r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) &&
934+ sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom)
834935 }
835936
836937 // sameLines reports whether two []string have the exact same
@@ -42,16 +42,24 @@ type Renderer struct {
42 logLines []string42 logLines []string
43 // Raw logical rows let DrawLog reject an unchanged frame before43 // Raw logical rows let DrawLog reject an unchanged frame before
44 // truncating and repainting every transcript row.44 // truncating and repainting every transcript row.
45- logRawChat []string45+ logRawChat []string
46- logRawBottom []string46+ logRawBottom []string
47- logRawCols int47+ logRawCols int
48- logRawRows int48+ logRawRows int
49- logRawBackground string49+ logRawContentCols int
50- logRawHasThemeBG bool50+ logRawBackground string
51- logViewportTop int51+ logRawHasThemeBG bool
52- logHardwareRow int52+ logViewportTop int
53- logInit bool53+ logHardwareRow int
54- logNeedsFullRepaint bool54+ logInit bool
55+
56+ // The right bar is a viewport overlay on top of the main-screen flow
57+ // renderer. Keeping its cache separate prevents persistent chrome from
58+ // replacing the transcript's logical buffer and native scrollback.
59+ rightBarActive bool
60+ rightBarLines []string
61+ rightBarMainCols int
62+ rightBarDimmed bool
55 63
56 // keepScrollback is true when we must NOT emit \x1b[3J64 // keepScrollback is true when we must NOT emit \x1b[3J
57 // (erase-in-display 3, "clear scrollback").65 // (erase-in-display 3, "clear scrollback").
@@ -130,6 +138,9 @@ func (r *Renderer) Resize(cols, rows int) {
130 r.logViewportTop = 0138 r.logViewportTop = 0
131 r.logHardwareRow = 0139 r.logHardwareRow = 0
132 r.logInit = false140 r.logInit = false
141+ r.rightBarActive = false
142+ r.rightBarLines = nil
143+ r.rightBarMainCols = 0
133 if r.out != nil {144 if r.out != nil {
134 if r.keepScrollback {145 if r.keepScrollback {
135 // A resize is a discrete user action, like Ctrl+L: the146 // A resize is a discrete user action, like Ctrl+L: the
@@ -166,6 +177,9 @@ func (r *Renderer) Clear() {
166 r.logViewportTop = 0177 r.logViewportTop = 0
167 r.logHardwareRow = 0178 r.logHardwareRow = 0
168 r.logInit = false179 r.logInit = false
180+ r.rightBarActive = false
181+ r.rightBarLines = nil
182+ r.rightBarMainCols = 0
169 if r.keepScrollback {183 if r.keepScrollback {
170 // On VS Code's xterm.js the transcript is taller than the184 // On VS Code's xterm.js the transcript is taller than the
171 // viewport, so an in-place clear (home + erase-to-end) only185 // viewport, so an in-place clear (home + erase-to-end) only
@@ -264,6 +278,7 @@ func truncateToWidth(s string, cols int) string {
264 var out strings.Builder278 var out strings.Builder
265 out.Grow(len(s))279 out.Grow(len(s))
266 seen := 0280 seen := 0
281+ osc8Active := false
267 runes := []rune(s)282 runes := []rune(s)
268 for i := 0; i < len(runes); {283 for i := 0; i < len(runes); {
269 r := runes[i]284 r := runes[i]
@@ -282,8 +297,41 @@ func truncateToWidth(s string, cols int) string {
282 }297 }
283 continue298 continue
284 }299 }
300+ // OSC string escape (BEL or ST terminated): zero-width and atomic.
301+ if r == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
302+ start := i
303+ i += 2
304+ for i < len(runes) {
305+ c := runes[i]
306+ i++
307+ if c == 0x07 {
308+ break
309+ }
310+ if c == 0x1b && i < len(runes) && runes[i] == '\\' {
311+ i++
312+ break
313+ }
314+ }
315+ osc8Closer := start+5 < i && runes[start+2] == '8' && runes[start+3] == ';' && runes[start+4] == ';' &&
316+ ((runes[start+5] == 0x07 && i == start+6) ||
317+ (start+6 < i && runes[start+5] == 0x1b && runes[start+6] == '\\' && i == start+7))
318+ if seen < cols || osc8Closer {
319+ for _, c := range runes[start:i] {
320+ out.WriteRune(c)
321+ }
322+ if runes[start+2] == '8' {
323+ osc8Active = !osc8Closer
324+ }
325+ }
326+ continue
327+ }
285 rw := runewidthRune(r)328 rw := runewidthRune(r)
286 if seen+rw > cols {329 if seen+rw > cols {
330+ if osc8Active {
331+ // The remaining text is omitted, so close a hyperlink
332+ // whose opener was already emitted before the boundary.
333+ out.WriteString("\x1b]8;;\x07")
334+ }
287 // Flush any trailing ANSI escapes (resets, erase-to-EOL)335 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
288 // so background colors and cleanup sequences survive.336 // so background colors and cleanup sequences survive.
289 for i < len(runes) {337 for i < len(runes) {
@@ -299,6 +347,31 @@ func truncateToWidth(s string, cols int) string {
299 break347 break
300 }348 }
301 }349 }
350+ } else if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
351+ // Do not append a new OSC sequence after the visible
352+ // boundary: an OSC 8 opener would leak hyperlink state
353+ // into the next terminal row. A matching OSC 8 closer is
354+ // retained so a link opened before the boundary is closed.
355+ keep := i+5 < len(runes) && runes[i+2] == '8' && runes[i+3] == ';' && runes[i+4] == ';' &&
356+ (runes[i+5] == 0x07 || (i+6 < len(runes) && runes[i+5] == 0x1b && runes[i+6] == '\\'))
357+ start := i
358+ i += 2
359+ for i < len(runes) {
360+ c := runes[i]
361+ i++
362+ if c == 0x07 {
363+ break
364+ }
365+ if c == 0x1b && i < len(runes) && runes[i] == '\\' {
366+ i++
367+ break
368+ }
369+ }
370+ if keep {
371+ for _, c := range runes[start:i] {
372+ out.WriteRune(c)
373+ }
374+ }
302 } else {375 } else {
303 break376 break
304 }377 }
@@ -432,26 +505,34 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
432 //505 //
433 // cursorBottomRow/cursorCol are offsets into bottom, not the full frame.506 // cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
434 func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {507 func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
508+ if r.rightBarActive {
509+ r.clearRightBarOverlay()
510+ }
511+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols)
512+}
513+
514+// drawLog is DrawLog's owning flow implementation. contentCols may reserve
515+// terminal columns for a separately composited viewport overlay.
516+func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
435 if r.cols == 0 || r.rows == 0 {517 if r.cols == 0 || r.rows == 0 {
436- return518+ return false
519+ }
520+ if contentCols <= 0 || contentCols > r.cols {
521+ contentCols = r.cols
437 }522 }
438 if len(bottom) == 0 {523 if len(bottom) == 0 {
439 bottom = []string{""}524 bottom = []string{""}
440 }525 }
441- if r.logInit && len(r.logLines) > 0 &&526+ if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) {
442- cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&527+ return false
443- r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle &&
444- r.logRawHasThemeBG == (r.theme.Background != nil) &&
445- sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
446- return
447 }528 }
448 chatFrame := make([]string, len(chat))529 chatFrame := make([]string, len(chat))
449 for i, line := range chat {530 for i, line := range chat {
450- chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)531+ chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
451 }532 }
452 bottomFrame := make([]string, len(bottom))533 bottomFrame := make([]string, len(bottom))
453 for i, line := range bottom {534 for i, line := range bottom {
454- bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)535+ bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
455 }536 }
456 537
457 // Always reserve one real row below the editor/status band. This is538 // Always reserve one real row below the editor/status band. This is
@@ -463,7 +544,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
463 lines = append(lines, chatFrame...)544 lines = append(lines, chatFrame...)
464 lines = append(lines, bottomFrame...)545 lines = append(lines, bottomFrame...)
465 for range bottomMarginRows {546 for range bottomMarginRows {
466- lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))547+ lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
467 }548 }
468 // In main-screen flow mode zut normally emits only its logical549 // In main-screen flow mode zut normally emits only its logical
469 // content rows and leaves the rest of the terminal viewport alone.550 // content rows and leaves the rest of the terminal viewport alone.
@@ -473,7 +554,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
473 // default transparent case.554 // default transparent case.
474 if r.theme.Background != nil {555 if r.theme.Background != nil {
475 for len(lines) < r.rows {556 for len(lines) < r.rows {
476- lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))557+ lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
477 }558 }
478 }559 }
479 if len(lines) == 0 {560 if len(lines) == 0 {
@@ -495,8 +576,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
495 // that never blinks, because we keep "showing" it before the576 // that never blinks, because we keep "showing" it before the
496 // terminal can blink it off. Bailing out here lets the OS run577 // terminal can blink it off. Bailing out here lets the OS run
497 // its blink cycle.578 // its blink cycle.
498- if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {579+ if r.logInit && r.logRawContentCols == contentCols &&
499- return580+ cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
581+ return false
500 }582 }
501 583
502 var w strings.Builder584 var w strings.Builder
@@ -590,8 +672,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
590 }672 }
591 moveToLogicalRow(cursorTargetRow)673 moveToLogicalRow(cursorTargetRow)
592 w.WriteString("\r")674 w.WriteString("\r")
593- if cursorCol > 0 {675+ physicalCursorCol := cursorCol
594- w.WriteString("\x1b[" + itoa(cursorCol) + "C")676+ if physicalCursorCol < 0 {
677+ physicalCursorCol = 0
678+ }
679+ if physicalCursorCol >= contentCols {
680+ physicalCursorCol = contentCols - 1
681+ }
682+ if physicalCursorCol > 0 {
683+ w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C")
595 }684 }
596 w.WriteString(SeqShowCursor)685 w.WriteString(SeqShowCursor)
597 }686 }
@@ -605,12 +694,10 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
605 wasInitialized := r.logInit694 wasInitialized := r.logInit
606 full := !wasInitialized || len(r.logLines) == 0695 full := !wasInitialized || len(r.logLines) == 0
607 if full {696 if full {
608- // Returning from the fixed right-bar frame needs a full viewport697+ // A cache invalidation may require a full viewport repaint after flow
609- // repaint, but it must not discard terminal scrollback merely because698+ // has started, but only the initial paint may discard old scrollback.
610- // the renderer's flow cache is intentionally empty.699+ writeFull(true, !wasInitialized)
611- writeFull(true, !wasInitialized && !r.logNeedsFullRepaint)
612 r.logInit = true700 r.logInit = true
613- r.logNeedsFullRepaint = false
614 } else {701 } else {
615 firstChanged := -1702 firstChanged := -1
616 lastChanged := -1703 lastChanged := -1
@@ -827,10 +914,24 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
827 r.logRawBottom = append(r.logRawBottom[:0], bottom...)914 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
828 r.logRawCols = r.cols915 r.logRawCols = r.cols
829 r.logRawRows = r.rows916 r.logRawRows = r.rows
917+ r.logRawContentCols = contentCols
830 r.logRawBackground = r.backgroundStyle918 r.logRawBackground = r.backgroundStyle
831 r.logRawHasThemeBG = r.theme.Background != nil919 r.logRawHasThemeBG = r.theme.Background != nil
832 r.cursorRow = cursorBottomRow920 r.cursorRow = cursorBottomRow
833 r.cursorCol = cursorCol921 r.cursorCol = cursorCol
922+ return true
923+}
924+
925+func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
926+ return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
927+ r.logRawContentUnchanged(chat, bottom, contentCols)
928+}
929+
930+func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool {
931+ return r.logInit && len(r.logLines) > 0 &&
932+ r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols &&
933+ r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) &&
934+ sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom)
834 }935 }
835 936
836 // sameLines reports whether two []string have the exact same937 // sameLines reports whether two []string have the exact same
M packages/tui/rightbar.go +92 -42
@@ -1,6 +1,7 @@
11 package tui
22
33 import (
4+ "io"
45 "sort"
56 "strings"
67 )
@@ -372,12 +373,9 @@ func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int,
372373 return main + separator + rightBar
373374 }
374375
375-// DrawRightBar renders an already-laid-out visible chat slice and sticky
376-// bottom block in a fixed frame with a persistent right bar. The normal
377-// DrawLog path deliberately uses terminal scrollback; a persistent side rail
378-// needs a fixed viewport so updates can repaint its columns without leaving
379-// stale rows behind. Interactive mode still owns chat scrolling and passes
380-// the selected visible slice here.
376+// DrawRightBar renders the complete transcript through DrawLog, then
377+// composites the persistent rail over the visible viewport. The flow cache
378+// remains authoritative so appended chat still enters native scrollback.
381379 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
382380 r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
383381 }
@@ -394,50 +392,102 @@ func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow
394392 }
395393 mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)
396394 if !ok {
395+ r.DrawLog(chat, bottom, cursorBottomRow, cursorCol)
397396 return
398397 }
398+
399399 if len(bottom) == 0 {
400400 bottom = []string{""}
401401 }
402- if len(rightBar) < r.rows {
403- padded := make([]string, r.rows)
404- copy(padded, rightBar)
405- rightBar = padded
406- } else if len(rightBar) > r.rows {
407- rightBar = rightBar[:r.rows]
408- }
409402
410- main := make([]string, 0, len(chat)+len(bottom)+1)
411- main = append(main, chat...)
412- main = append(main, bottom...)
413- main = append(main, "") // mirror DrawLog's renderer-owned bottom margin
414- cursorRow := len(chat) + cursorBottomRow
415- if len(main) > r.rows {
416- trim := len(main) - r.rows
417- main = main[trim:]
418- cursorRow -= trim
419- }
420- if len(main) < r.rows {
421- padding := make([]string, r.rows-len(main))
422- main = append(padding, main...)
423- cursorRow += len(padding)
403+ // Keep DrawLog's final blank margin unobstructed, matching the normal flow
404+ // renderer and the previous right-bar layout.
405+ frameRows := max(0, r.rows-1)
406+ barLines := make([]string, frameRows)
407+ copy(barLines, rightBar)
408+
409+ barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth ||
410+ r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines)
411+ contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth)
412+ flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol
413+ if flowUnchanged && !barChanged {
414+ return
415+ }
416+ if !contentUnchanged && r.rightBarActive {
417+ // Remove viewport chrome before DrawLog can naturally scroll a row into
418+ // history. Otherwise every appended transcript row captures a stale
419+ // copy of the rail in native terminal scrollback.
420+ r.clearRightBarOverlay()
424421 }
422+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth)
423+ if contentUnchanged && !barChanged {
424+ // Cursor-only updates do not disturb the composited rail.
425+ return
426+ }
427+ r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator)
428+ r.rightBarActive = true
429+ r.rightBarLines = append(r.rightBarLines[:0], barLines...)
430+ r.rightBarMainCols = mainWidth
431+ r.rightBarDimmed = dimSeparator
432+}
425433
426- frame := make([]string, r.rows)
427- for row := range frame {
428- frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator)
434+// clearRightBarOverlay removes viewport chrome before DrawLog expands back to
435+// the terminal width. Restoring the tracked flow cursor keeps DrawLog's
436+// hardware-row model valid across the intermediate absolute writes.
437+func (r *Renderer) clearRightBarOverlay() {
438+ if !r.rightBarActive {
439+ return
429440 }
441+ remaining := r.cols - r.rightBarMainCols
442+ if remaining > 0 {
443+ blank := strings.Repeat(" ", remaining)
444+ var w strings.Builder
445+ w.WriteString(SeqSynchronizedOn)
446+ w.WriteString(SeqHideCursor)
447+ for row := 0; row < max(0, r.rows-1); row++ {
448+ w.WriteString(MoveTo(row+1, r.rightBarMainCols+1))
449+ w.WriteString("\x1b[0m")
450+ w.WriteString(blank)
451+ }
452+ r.appendTrackedLogCursor(&w, r.rightBarMainCols)
453+ w.WriteString(SeqSynchronizedOff)
454+ _, _ = io.WriteString(r.out, w.String())
455+ }
456+ r.rightBarActive = false
457+ r.rightBarLines = nil
458+ r.rightBarMainCols = 0
459+ r.rightBarDimmed = false
460+}
461+
462+func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) {
463+ var w strings.Builder
464+ w.WriteString(SeqSynchronizedOn)
465+ w.WriteString(SeqHideCursor)
466+ for row, line := range rightBar {
467+ w.WriteString(MoveTo(row+1, mainWidth+1))
468+ w.WriteString("\x1b[0m")
469+ w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator))
470+ }
471+ r.appendTrackedLogCursor(&w, mainWidth)
472+ w.WriteString(SeqSynchronizedOff)
473+ _, _ = io.WriteString(r.out, w.String())
474+}
430475
431- // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
432- // later fallback to DrawLog starts with a full repaint instead of trying
433- // to diff against logical rows from before the rail was enabled. Mark the
434- // transition separately so that repaint does not clear scrollback.
435- r.logChat = nil
436- r.logBottom = nil
437- r.logLines = nil
438- r.logViewportTop = 0
439- r.logHardwareRow = 0
440- r.logInit = false
441- r.logNeedsFullRepaint = true
442- r.Draw(frame, cursorRow, cursorCol)
476+func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) {
477+ if !r.logInit || r.cursorRow < 0 {
478+ return
479+ }
480+ row := len(r.logChat) + r.cursorRow - r.logViewportTop
481+ if row < 0 || row >= r.rows {
482+ return
483+ }
484+ cursorCol := r.cursorCol
485+ if cursorCol < 0 {
486+ cursorCol = 0
487+ }
488+ if cursorCol >= contentCols {
489+ cursorCol = contentCols - 1
490+ }
491+ w.WriteString(MoveTo(row+1, cursorCol+1))
492+ w.WriteString(SeqShowCursor)
443493 }
@@ -1,6 +1,7 @@
1 package tui1 package tui
2 2
3 import (3 import (
4+ "io"
4 "sort"5 "sort"
5 "strings"6 "strings"
6 )7 )
@@ -372,12 +373,9 @@ func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int,
372 return main + separator + rightBar373 return main + separator + rightBar
373 }374 }
374 375
375-// DrawRightBar renders an already-laid-out visible chat slice and sticky376+// DrawRightBar renders the complete transcript through DrawLog, then
376-// bottom block in a fixed frame with a persistent right bar. The normal377+// composites the persistent rail over the visible viewport. The flow cache
377-// DrawLog path deliberately uses terminal scrollback; a persistent side rail378+// remains authoritative so appended chat still enters native scrollback.
378-// needs a fixed viewport so updates can repaint its columns without leaving
379-// stale rows behind. Interactive mode still owns chat scrolling and passes
380-// the selected visible slice here.
381 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {379 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
382 r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)380 r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
383 }381 }
@@ -394,50 +392,102 @@ func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow
394 }392 }
395 mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)393 mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)
396 if !ok {394 if !ok {
395+ r.DrawLog(chat, bottom, cursorBottomRow, cursorCol)
397 return396 return
398 }397 }
398+
399 if len(bottom) == 0 {399 if len(bottom) == 0 {
400 bottom = []string{""}400 bottom = []string{""}
401 }401 }
402- if len(rightBar) < r.rows {
403- padded := make([]string, r.rows)
404- copy(padded, rightBar)
405- rightBar = padded
406- } else if len(rightBar) > r.rows {
407- rightBar = rightBar[:r.rows]
408- }
409 402
410- main := make([]string, 0, len(chat)+len(bottom)+1)403+ // Keep DrawLog's final blank margin unobstructed, matching the normal flow
411- main = append(main, chat...)404+ // renderer and the previous right-bar layout.
412- main = append(main, bottom...)405+ frameRows := max(0, r.rows-1)
413- main = append(main, "") // mirror DrawLog's renderer-owned bottom margin406+ barLines := make([]string, frameRows)
414- cursorRow := len(chat) + cursorBottomRow407+ copy(barLines, rightBar)
415- if len(main) > r.rows {408+
416- trim := len(main) - r.rows409+ barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth ||
417- main = main[trim:]410+ r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines)
418- cursorRow -= trim411+ contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth)
419- }412+ flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol
420- if len(main) < r.rows {413+ if flowUnchanged && !barChanged {
421- padding := make([]string, r.rows-len(main))414+ return
422- main = append(padding, main...)415+ }
423- cursorRow += len(padding)416+ if !contentUnchanged && r.rightBarActive {
417+ // Remove viewport chrome before DrawLog can naturally scroll a row into
418+ // history. Otherwise every appended transcript row captures a stale
419+ // copy of the rail in native terminal scrollback.
420+ r.clearRightBarOverlay()
424 }421 }
422+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth)
423+ if contentUnchanged && !barChanged {
424+ // Cursor-only updates do not disturb the composited rail.
425+ return
426+ }
427+ r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator)
428+ r.rightBarActive = true
429+ r.rightBarLines = append(r.rightBarLines[:0], barLines...)
430+ r.rightBarMainCols = mainWidth
431+ r.rightBarDimmed = dimSeparator
432+}
425 433
426- frame := make([]string, r.rows)434+// clearRightBarOverlay removes viewport chrome before DrawLog expands back to
427- for row := range frame {435+// the terminal width. Restoring the tracked flow cursor keeps DrawLog's
428- frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator)436+// hardware-row model valid across the intermediate absolute writes.
437+func (r *Renderer) clearRightBarOverlay() {
438+ if !r.rightBarActive {
439+ return
429 }440 }
441+ remaining := r.cols - r.rightBarMainCols
442+ if remaining > 0 {
443+ blank := strings.Repeat(" ", remaining)
444+ var w strings.Builder
445+ w.WriteString(SeqSynchronizedOn)
446+ w.WriteString(SeqHideCursor)
447+ for row := 0; row < max(0, r.rows-1); row++ {
448+ w.WriteString(MoveTo(row+1, r.rightBarMainCols+1))
449+ w.WriteString("\x1b[0m")
450+ w.WriteString(blank)
451+ }
452+ r.appendTrackedLogCursor(&w, r.rightBarMainCols)
453+ w.WriteString(SeqSynchronizedOff)
454+ _, _ = io.WriteString(r.out, w.String())
455+ }
456+ r.rightBarActive = false
457+ r.rightBarLines = nil
458+ r.rightBarMainCols = 0
459+ r.rightBarDimmed = false
460+}
461+
462+func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) {
463+ var w strings.Builder
464+ w.WriteString(SeqSynchronizedOn)
465+ w.WriteString(SeqHideCursor)
466+ for row, line := range rightBar {
467+ w.WriteString(MoveTo(row+1, mainWidth+1))
468+ w.WriteString("\x1b[0m")
469+ w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator))
470+ }
471+ r.appendTrackedLogCursor(&w, mainWidth)
472+ w.WriteString(SeqSynchronizedOff)
473+ _, _ = io.WriteString(r.out, w.String())
474+}
430 475
431- // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a476+func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) {
432- // later fallback to DrawLog starts with a full repaint instead of trying477+ if !r.logInit || r.cursorRow < 0 {
433- // to diff against logical rows from before the rail was enabled. Mark the478+ return
434- // transition separately so that repaint does not clear scrollback.479+ }
435- r.logChat = nil480+ row := len(r.logChat) + r.cursorRow - r.logViewportTop
436- r.logBottom = nil481+ if row < 0 || row >= r.rows {
437- r.logLines = nil482+ return
438- r.logViewportTop = 0483+ }
439- r.logHardwareRow = 0484+ cursorCol := r.cursorCol
440- r.logInit = false485+ if cursorCol < 0 {
441- r.logNeedsFullRepaint = true486+ cursorCol = 0
442- r.Draw(frame, cursorRow, cursorCol)487+ }
488+ if cursorCol >= contentCols {
489+ cursorCol = contentCols - 1
490+ }
491+ w.WriteString(MoveTo(row+1, cursorCol+1))
492+ w.WriteString(SeqShowCursor)
443 }493 }
M packages/tui/rightbar_test.go +152 -6
@@ -209,6 +209,141 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
209209 }
210210 }
211211
212+func TestRightBarTruncationPreservesOSCSequences(t *testing.T) {
213+ for _, tc := range []struct {
214+ opener string
215+ closer string
216+ }{
217+ {"\x1b]8;;https://example.test\x07", "\x1b]8;;\x07"},
218+ {"\x1b]8;;https://example.test\x1b\\", "\x1b]8;;\x1b\\"},
219+ } {
220+ got := JoinRightBar(Dark, tc.opener+"label-more", "", 5, 1)
221+ if !strings.HasPrefix(got, tc.opener+"label") {
222+ t.Fatalf("truncated right-bar row split OSC escape %q: %q", tc.opener, got)
223+ }
224+ got = truncateToWidth("12345"+tc.opener+"tail", 5)
225+ if got != "12345" {
226+ t.Fatalf("truncated right-bar row leaked trailing OSC escape %q: %q", tc.opener, got)
227+ }
228+ got = truncateToWidth(tc.opener+"12345x"+tc.closer, 5)
229+ if !strings.HasSuffix(got, "\x1b]8;;\x07") && !strings.HasSuffix(got, tc.closer) {
230+ t.Fatalf("truncated right-bar row left OSC 8 hyperlink open: %q", got)
231+ }
232+ }
233+}
234+
235+func TestRendererDrawRightBarKeepsFlowScrollback(t *testing.T) {
236+ t.Setenv("TERM_PROGRAM", "")
237+
238+ var out bytes.Buffer
239+ r := NewRenderer(&out)
240+ r.SetTheme(Dark)
241+ r.Resize(80, 5)
242+ chat := []string{"one", "two", "three", "four", "five", "six"}
243+ r.DrawRightBar(
244+ chat,
245+ []string{"input"},
246+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
247+ 0,
248+ 0,
249+ )
250+ if !r.logInit {
251+ t.Fatal("right-bar draw abandoned the flow renderer")
252+ }
253+ if got := len(r.logChat); got != len(chat) {
254+ t.Fatalf("cached chat rows = %d, want complete transcript length %d", got, len(chat))
255+ }
256+
257+ out.Reset()
258+ chat = append(chat, "seven")
259+ r.DrawRightBar(
260+ chat,
261+ []string{"input"},
262+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
263+ 0,
264+ 0,
265+ )
266+ got := out.String()
267+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
268+ t.Fatalf("right-bar append cleared retained scrollback: %q", got)
269+ }
270+ if !strings.Contains(stripANSI(got), "seven") {
271+ t.Fatalf("right-bar append omitted new transcript row: %q", got)
272+ }
273+ mainWidth, _, _ := RightBarColumns(80)
274+ clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
275+ if clearAt, appendAt := strings.Index(got, clearedRail), strings.Index(got, "seven"); clearAt < 0 || clearAt > appendAt {
276+ t.Fatalf("right bar was not removed before transcript append entered scrollback: %q", got)
277+ }
278+ if r.logViewportTop == 0 {
279+ t.Fatal("test did not exercise transcript rows above the viewport")
280+ }
281+}
282+
283+func TestRendererDrawRightBarIdleNoOpEmitsNothing(t *testing.T) {
284+ t.Setenv("TERM_PROGRAM", "")
285+ var out bytes.Buffer
286+ r := NewRenderer(&out)
287+ r.SetTheme(Dark)
288+ r.Resize(80, 5)
289+ chat := []string{"chat"}
290+ bottom := []string{"input"}
291+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
292+ r.DrawRightBar(chat, bottom, rail, 0, 0)
293+ out.Reset()
294+
295+ r.DrawRightBar(chat, bottom, rail, 0, 0)
296+ if out.Len() != 0 {
297+ t.Fatalf("idle right-bar redraw emitted %d bytes: %q", out.Len(), out.String())
298+ }
299+}
300+
301+func TestRendererDrawRightBarCursorMoveDoesNotRepaintRail(t *testing.T) {
302+ t.Setenv("TERM_PROGRAM", "")
303+ var out bytes.Buffer
304+ r := NewRenderer(&out)
305+ r.SetTheme(Dark)
306+ r.Resize(80, 5)
307+ chat := []string{"chat"}
308+ bottom := []string{"input"}
309+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
310+ r.DrawRightBar(chat, bottom, rail, 0, 0)
311+ out.Reset()
312+
313+ r.DrawRightBar(chat, bottom, rail, 0, 1)
314+ if out.Len() == 0 {
315+ t.Fatal("cursor-only right-bar update emitted nothing")
316+ }
317+ if !strings.Contains(out.String(), "\r\x1b[1C") {
318+ t.Fatalf("cursor-only update did not move the cursor to column 1: %q", out.String())
319+ }
320+ if strings.Contains(stripANSI(out.String()), "task") {
321+ t.Fatalf("cursor-only update repainted the entire rail: %q", out.String())
322+ }
323+}
324+
325+func TestRendererDrawRightBarClampsCursorToMainContent(t *testing.T) {
326+ t.Setenv("TERM_PROGRAM", "")
327+ var out bytes.Buffer
328+ r := NewRenderer(&out)
329+ r.SetTheme(Dark)
330+ r.Resize(80, 5)
331+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
332+ r.DrawRightBar([]string{"chat"}, []string{"input"}, rail, 0, 100)
333+
334+ mainWidth, _, ok := RightBarColumns(80)
335+ if !ok {
336+ t.Fatal("right bar should fit")
337+ }
338+ got := out.String()
339+ if strings.Contains(got, MoveTo(2, 101)) {
340+ t.Fatalf("cursor escaped the main content area: %q", got)
341+ }
342+ if !strings.Contains(got, MoveTo(2, mainWidth)) {
343+ t.Fatalf("cursor was not clamped to the main content edge: %q", got)
344+ }
345+}
346+
212347 func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
213348 t.Setenv("TERM_PROGRAM", "")
214349
@@ -226,10 +361,21 @@ func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
226361
227362 out.Reset()
228363 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
229- if !strings.Contains(stripANSI(out.String()), "input") {
230- t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String())
364+ if r.rightBarActive {
365+ t.Fatal("right-bar fallback left the viewport overlay active")
366+ }
367+ if r.logRawContentCols != 80 {
368+ t.Fatalf("flow content width = %d, want full terminal width 80", r.logRawContentCols)
231369 }
232- if strings.Contains(out.String(), SeqClearScrollback) {
370+ mainWidth, _, ok := RightBarColumns(80)
371+ if !ok {
372+ t.Fatal("right bar should fit before falling back to DrawLog")
373+ }
374+ clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
375+ if !strings.Contains(out.String(), clearedRail) {
376+ t.Fatalf("right-bar fallback did not clear the old rail: %q", out.String())
377+ }
378+ if strings.Contains(out.String(), SeqClearScreenNoHome) || strings.Contains(out.String(), SeqClearScrollback) {
233379 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
234380 }
235381 }
@@ -261,14 +407,14 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
261407 if !strings.Contains(stripANSI(out.String()), "Updated") {
262408 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
263409 }
264- if r.logInit {
265- t.Fatal("right-bar mode left flow renderer initialized")
410+ if !r.logInit {
411+ t.Fatal("right-bar update abandoned the flow renderer")
266412 }
267413
268414 out.Reset()
269415 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
270416 if !r.logInit {
271- t.Fatal("flow renderer was not reinitialized after right-bar clearing")
417+ t.Fatal("flow renderer was not retained after right-bar clearing")
272418 }
273419
274420 out.Reset()
@@ -209,6 +209,141 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
209 }209 }
210 }210 }
211 211
212+func TestRightBarTruncationPreservesOSCSequences(t *testing.T) {
213+ for _, tc := range []struct {
214+ opener string
215+ closer string
216+ }{
217+ {"\x1b]8;;https://example.test\x07", "\x1b]8;;\x07"},
218+ {"\x1b]8;;https://example.test\x1b\\", "\x1b]8;;\x1b\\"},
219+ } {
220+ got := JoinRightBar(Dark, tc.opener+"label-more", "", 5, 1)
221+ if !strings.HasPrefix(got, tc.opener+"label") {
222+ t.Fatalf("truncated right-bar row split OSC escape %q: %q", tc.opener, got)
223+ }
224+ got = truncateToWidth("12345"+tc.opener+"tail", 5)
225+ if got != "12345" {
226+ t.Fatalf("truncated right-bar row leaked trailing OSC escape %q: %q", tc.opener, got)
227+ }
228+ got = truncateToWidth(tc.opener+"12345x"+tc.closer, 5)
229+ if !strings.HasSuffix(got, "\x1b]8;;\x07") && !strings.HasSuffix(got, tc.closer) {
230+ t.Fatalf("truncated right-bar row left OSC 8 hyperlink open: %q", got)
231+ }
232+ }
233+}
234+
235+func TestRendererDrawRightBarKeepsFlowScrollback(t *testing.T) {
236+ t.Setenv("TERM_PROGRAM", "")
237+
238+ var out bytes.Buffer
239+ r := NewRenderer(&out)
240+ r.SetTheme(Dark)
241+ r.Resize(80, 5)
242+ chat := []string{"one", "two", "three", "four", "five", "six"}
243+ r.DrawRightBar(
244+ chat,
245+ []string{"input"},
246+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
247+ 0,
248+ 0,
249+ )
250+ if !r.logInit {
251+ t.Fatal("right-bar draw abandoned the flow renderer")
252+ }
253+ if got := len(r.logChat); got != len(chat) {
254+ t.Fatalf("cached chat rows = %d, want complete transcript length %d", got, len(chat))
255+ }
256+
257+ out.Reset()
258+ chat = append(chat, "seven")
259+ r.DrawRightBar(
260+ chat,
261+ []string{"input"},
262+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
263+ 0,
264+ 0,
265+ )
266+ got := out.String()
267+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
268+ t.Fatalf("right-bar append cleared retained scrollback: %q", got)
269+ }
270+ if !strings.Contains(stripANSI(got), "seven") {
271+ t.Fatalf("right-bar append omitted new transcript row: %q", got)
272+ }
273+ mainWidth, _, _ := RightBarColumns(80)
274+ clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
275+ if clearAt, appendAt := strings.Index(got, clearedRail), strings.Index(got, "seven"); clearAt < 0 || clearAt > appendAt {
276+ t.Fatalf("right bar was not removed before transcript append entered scrollback: %q", got)
277+ }
278+ if r.logViewportTop == 0 {
279+ t.Fatal("test did not exercise transcript rows above the viewport")
280+ }
281+}
282+
283+func TestRendererDrawRightBarIdleNoOpEmitsNothing(t *testing.T) {
284+ t.Setenv("TERM_PROGRAM", "")
285+ var out bytes.Buffer
286+ r := NewRenderer(&out)
287+ r.SetTheme(Dark)
288+ r.Resize(80, 5)
289+ chat := []string{"chat"}
290+ bottom := []string{"input"}
291+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
292+ r.DrawRightBar(chat, bottom, rail, 0, 0)
293+ out.Reset()
294+
295+ r.DrawRightBar(chat, bottom, rail, 0, 0)
296+ if out.Len() != 0 {
297+ t.Fatalf("idle right-bar redraw emitted %d bytes: %q", out.Len(), out.String())
298+ }
299+}
300+
301+func TestRendererDrawRightBarCursorMoveDoesNotRepaintRail(t *testing.T) {
302+ t.Setenv("TERM_PROGRAM", "")
303+ var out bytes.Buffer
304+ r := NewRenderer(&out)
305+ r.SetTheme(Dark)
306+ r.Resize(80, 5)
307+ chat := []string{"chat"}
308+ bottom := []string{"input"}
309+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
310+ r.DrawRightBar(chat, bottom, rail, 0, 0)
311+ out.Reset()
312+
313+ r.DrawRightBar(chat, bottom, rail, 0, 1)
314+ if out.Len() == 0 {
315+ t.Fatal("cursor-only right-bar update emitted nothing")
316+ }
317+ if !strings.Contains(out.String(), "\r\x1b[1C") {
318+ t.Fatalf("cursor-only update did not move the cursor to column 1: %q", out.String())
319+ }
320+ if strings.Contains(stripANSI(out.String()), "task") {
321+ t.Fatalf("cursor-only update repainted the entire rail: %q", out.String())
322+ }
323+}
324+
325+func TestRendererDrawRightBarClampsCursorToMainContent(t *testing.T) {
326+ t.Setenv("TERM_PROGRAM", "")
327+ var out bytes.Buffer
328+ r := NewRenderer(&out)
329+ r.SetTheme(Dark)
330+ r.Resize(80, 5)
331+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
332+ r.DrawRightBar([]string{"chat"}, []string{"input"}, rail, 0, 100)
333+
334+ mainWidth, _, ok := RightBarColumns(80)
335+ if !ok {
336+ t.Fatal("right bar should fit")
337+ }
338+ got := out.String()
339+ if strings.Contains(got, MoveTo(2, 101)) {
340+ t.Fatalf("cursor escaped the main content area: %q", got)
341+ }
342+ if !strings.Contains(got, MoveTo(2, mainWidth)) {
343+ t.Fatalf("cursor was not clamped to the main content edge: %q", got)
344+ }
345+}
346+
212 func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {347 func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
213 t.Setenv("TERM_PROGRAM", "")348 t.Setenv("TERM_PROGRAM", "")
214 349
@@ -226,10 +361,21 @@ func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
226 361
227 out.Reset()362 out.Reset()
228 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)363 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
229- if !strings.Contains(stripANSI(out.String()), "input") {364+ if r.rightBarActive {
230- t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String())365+ t.Fatal("right-bar fallback left the viewport overlay active")
366+ }
367+ if r.logRawContentCols != 80 {
368+ t.Fatalf("flow content width = %d, want full terminal width 80", r.logRawContentCols)
231 }369 }
232- if strings.Contains(out.String(), SeqClearScrollback) {370+ mainWidth, _, ok := RightBarColumns(80)
371+ if !ok {
372+ t.Fatal("right bar should fit before falling back to DrawLog")
373+ }
374+ clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
375+ if !strings.Contains(out.String(), clearedRail) {
376+ t.Fatalf("right-bar fallback did not clear the old rail: %q", out.String())
377+ }
378+ if strings.Contains(out.String(), SeqClearScreenNoHome) || strings.Contains(out.String(), SeqClearScrollback) {
233 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())379 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
234 }380 }
235 }381 }
@@ -261,14 +407,14 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
261 if !strings.Contains(stripANSI(out.String()), "Updated") {407 if !strings.Contains(stripANSI(out.String()), "Updated") {
262 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())408 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
263 }409 }
264- if r.logInit {410+ if !r.logInit {
265- t.Fatal("right-bar mode left flow renderer initialized")411+ t.Fatal("right-bar update abandoned the flow renderer")
266 }412 }
267 413
268 out.Reset()414 out.Reset()
269 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)415 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
270 if !r.logInit {416 if !r.logInit {
271- t.Fatal("flow renderer was not reinitialized after right-bar clearing")417+ t.Fatal("flow renderer was not retained after right-bar clearing")
272 }418 }
273 419
274 out.Reset()420 out.Reset()

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