detached 4eba8ac

feat(tui): add floating panes

commit
4eba8ac7cd9e67b2fe35bf156355416ef13e0296
author
brice · · 2026-08-14 10:06 UTC
committer
brice · · 2026-08-14 11:20 UTC
parent
b3989ce
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +209 -38
M packages/agent/modes/interactive.go +24 -1
@@ -772,6 +772,7 @@ type Interactive struct {
772772 sessionDialog *sessionDialog
773773 residentSubagentsDialog *residentSubagentsDialog
774774 residentChildSession *residentChildSession
775+ residentChildMouse bool
775776 jumpDialog *jumpDialog
776777 btwDialog *btwDialog
777778 skillsDialog *skillsDialog
@@ -1063,6 +1064,28 @@ func (i *Interactive) markCtrlCExit() {
10631064 i.mu.Unlock()
10641065 }
10651066
1067+// setResidentChildMouseReporting enables mouse reporting only while the
1068+// transcript overlay owns scrolling. Normal chat keeps native terminal
1069+// selection and wheel scrolling intact.
1070+func (i *Interactive) setResidentChildMouseReporting(enabled bool) {
1071+ i.mu.Lock()
1072+ if i.residentChildMouse == enabled {
1073+ i.mu.Unlock()
1074+ return
1075+ }
1076+ i.residentChildMouse = enabled
1077+ term, running := i.cfg.Terminal, i.runCtx != nil
1078+ i.mu.Unlock()
1079+ if term == nil || !running {
1080+ return
1081+ }
1082+ seq := tui.SeqMouseOff
1083+ if enabled {
1084+ seq = tui.SeqMouseOn
1085+ }
1086+ _, _ = term.Write([]byte(seq))
1087+}
1088+
10661089 // Run blocks until the user quits.
10671090 func (i *Interactive) Run(ctx context.Context) error {
10681091 i.mu.Lock()
@@ -1103,7 +1126,7 @@ func (i *Interactive) Run(ctx context.Context) error {
11031126 // Erase the live frame and place the cursor deterministically before any
11041127 // exit summary or shell prompt is written. Do not erase scrollback: users
11051128 // should still be able to review the session after closing zut.
1106- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
1129+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
11071130 i.applyInputCursorColor()
11081131
11091132 // Streaming pacer: drains buffered text deltas at a steady rate
@@ -772,6 +772,7 @@ type Interactive struct {
772 sessionDialog *sessionDialog772 sessionDialog *sessionDialog
773 residentSubagentsDialog *residentSubagentsDialog773 residentSubagentsDialog *residentSubagentsDialog
774 residentChildSession *residentChildSession774 residentChildSession *residentChildSession
775+ residentChildMouse bool
775 jumpDialog *jumpDialog776 jumpDialog *jumpDialog
776 btwDialog *btwDialog777 btwDialog *btwDialog
777 skillsDialog *skillsDialog778 skillsDialog *skillsDialog
@@ -1063,6 +1064,28 @@ func (i *Interactive) markCtrlCExit() {
1063 i.mu.Unlock()1064 i.mu.Unlock()
1064 }1065 }
1065 1066
1067+// setResidentChildMouseReporting enables mouse reporting only while the
1068+// transcript overlay owns scrolling. Normal chat keeps native terminal
1069+// selection and wheel scrolling intact.
1070+func (i *Interactive) setResidentChildMouseReporting(enabled bool) {
1071+ i.mu.Lock()
1072+ if i.residentChildMouse == enabled {
1073+ i.mu.Unlock()
1074+ return
1075+ }
1076+ i.residentChildMouse = enabled
1077+ term, running := i.cfg.Terminal, i.runCtx != nil
1078+ i.mu.Unlock()
1079+ if term == nil || !running {
1080+ return
1081+ }
1082+ seq := tui.SeqMouseOff
1083+ if enabled {
1084+ seq = tui.SeqMouseOn
1085+ }
1086+ _, _ = term.Write([]byte(seq))
1087+}
1088+
1066 // Run blocks until the user quits.1089 // Run blocks until the user quits.
1067 func (i *Interactive) Run(ctx context.Context) error {1090 func (i *Interactive) Run(ctx context.Context) error {
1068 i.mu.Lock()1091 i.mu.Lock()
@@ -1103,7 +1126,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1103 // Erase the live frame and place the cursor deterministically before any1126 // Erase the live frame and place the cursor deterministically before any
1104 // exit summary or shell prompt is written. Do not erase scrollback: users1127 // exit summary or shell prompt is written. Do not erase scrollback: users
1105 // should still be able to review the session after closing zut.1128 // should still be able to review the session after closing zut.
1106- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))1129+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
1107 i.applyInputCursorColor()1130 i.applyInputCursorColor()
1108 1131
1109 // Streaming pacer: drains buffered text deltas at a steady rate1132 // Streaming pacer: drains buffered text deltas at a steady rate
M packages/agent/modes/interactive_input_routing.go +6 -13
@@ -275,22 +275,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
275275 i.residentChildSession = nil
276276 }
277277 i.mu.Unlock()
278+ residentSession.Close()
279+ i.setResidentChildMouseReporting(false)
278280 case tui.KeyPageUp:
279- session := residentSession
280- if session.BeginLoad() {
281- go func() {
282- err := session.LoadOlder(200)
283- if session.FinishLoad(err) {
284- i.reloadResidentChildSession(session)
285- }
286- i.invalidate()
287- }()
288- }
281+ residentSession.Scroll(10)
289282 case tui.KeyPageDown:
290- residentSession.FollowTail()
291- case tui.KeyUp:
283+ residentSession.Scroll(-10)
284+ case tui.KeyUp, tui.KeyMouseWheelUp:
292285 residentSession.Scroll(1)
293- case tui.KeyDown:
286+ case tui.KeyDown, tui.KeyMouseWheelDown:
294287 residentSession.Scroll(-1)
295288 default:
296289 session := residentSession
@@ -275,22 +275,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
275 i.residentChildSession = nil275 i.residentChildSession = nil
276 }276 }
277 i.mu.Unlock()277 i.mu.Unlock()
278+ residentSession.Close()
279+ i.setResidentChildMouseReporting(false)
278 case tui.KeyPageUp:280 case tui.KeyPageUp:
279- session := residentSession281+ residentSession.Scroll(10)
280- if session.BeginLoad() {
281- go func() {
282- err := session.LoadOlder(200)
283- if session.FinishLoad(err) {
284- i.reloadResidentChildSession(session)
285- }
286- i.invalidate()
287- }()
288- }
289 case tui.KeyPageDown:282 case tui.KeyPageDown:
290- residentSession.FollowTail()283+ residentSession.Scroll(-10)
291- case tui.KeyUp:284+ case tui.KeyUp, tui.KeyMouseWheelUp:
292 residentSession.Scroll(1)285 residentSession.Scroll(1)
293- case tui.KeyDown:286+ case tui.KeyDown, tui.KeyMouseWheelDown:
294 residentSession.Scroll(-1)287 residentSession.Scroll(-1)
295 default:288 default:
296 session := residentSession289 session := residentSession
M packages/agent/modes/interactive_rendering.go +15 -2
@@ -540,6 +540,9 @@ func (i *Interactive) redraw() {
540540 dialog = i.btwDialog.Render(i.cfg.Theme, dialogWidth)
541541 case i.skillsDialog.Active():
542542 dialogID = "skills"
543+ if i.skillsDialog.Viewing() {
544+ dialogID = "skills-body"
545+ }
543546 dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth)
544547 case i.changelogDialog.Active():
545548 dialogID = "changelog"
@@ -561,11 +564,15 @@ func (i *Interactive) redraw() {
561564 i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
562565 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)
563566 }
564- dialogTitle := ""
567+ dialogTitle := floatingOverlayTitle(dialogID)
565568 dialogRemovedTopRows := 0
566569 if len(dialog) > 0 {
567570 dialog = padDialogFrame(dialog)
568- dialogTitle, dialog, dialogRemovedTopRows = floatingDialogBody(dialog)
571+ frameTitle, body, removedTopRows := floatingDialogBody(dialog)
572+ if frameTitle != "" {
573+ dialogTitle = frameTitle
574+ }
575+ dialog, dialogRemovedTopRows = body, removedTopRows
569576 }
570577 dialogFocusRow := -1
571578 for row, line := range dialog {
@@ -1065,6 +1072,12 @@ func (i *Interactive) redraw() {
10651072 i.mu.Unlock()
10661073 }
10671074
1075+// floatingOverlayTitle supplies every overlay with a stable, human-readable
1076+// title even when its body does not use the legacy frame-header convention.
1077+func floatingOverlayTitle(id string) string {
1078+ return strings.ReplaceAll(id, "-", " ")
1079+}
1080+
10681081 // floatingDialogBody removes a legacy dialog's rule chrome and returns its
10691082 // title for the shared floating-pane border.
10701083 func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {
@@ -540,6 +540,9 @@ func (i *Interactive) redraw() {
540 dialog = i.btwDialog.Render(i.cfg.Theme, dialogWidth)540 dialog = i.btwDialog.Render(i.cfg.Theme, dialogWidth)
541 case i.skillsDialog.Active():541 case i.skillsDialog.Active():
542 dialogID = "skills"542 dialogID = "skills"
543+ if i.skillsDialog.Viewing() {
544+ dialogID = "skills-body"
545+ }
543 dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth)546 dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth)
544 case i.changelogDialog.Active():547 case i.changelogDialog.Active():
545 dialogID = "changelog"548 dialogID = "changelog"
@@ -561,11 +564,15 @@ func (i *Interactive) redraw() {
561 i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)564 i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
562 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)565 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)
563 }566 }
564- dialogTitle := ""567+ dialogTitle := floatingOverlayTitle(dialogID)
565 dialogRemovedTopRows := 0568 dialogRemovedTopRows := 0
566 if len(dialog) > 0 {569 if len(dialog) > 0 {
567 dialog = padDialogFrame(dialog)570 dialog = padDialogFrame(dialog)
568- dialogTitle, dialog, dialogRemovedTopRows = floatingDialogBody(dialog)571+ frameTitle, body, removedTopRows := floatingDialogBody(dialog)
572+ if frameTitle != "" {
573+ dialogTitle = frameTitle
574+ }
575+ dialog, dialogRemovedTopRows = body, removedTopRows
569 }576 }
570 dialogFocusRow := -1577 dialogFocusRow := -1
571 for row, line := range dialog {578 for row, line := range dialog {
@@ -1065,6 +1072,12 @@ func (i *Interactive) redraw() {
1065 i.mu.Unlock()1072 i.mu.Unlock()
1066 }1073 }
1067 1074
1075+// floatingOverlayTitle supplies every overlay with a stable, human-readable
1076+// title even when its body does not use the legacy frame-header convention.
1077+func floatingOverlayTitle(id string) string {
1078+ return strings.ReplaceAll(id, "-", " ")
1079+}
1080+
1068 // floatingDialogBody removes a legacy dialog's rule chrome and returns its1081 // floatingDialogBody removes a legacy dialog's rule chrome and returns its
1069 // title for the shared floating-pane border.1082 // title for the shared floating-pane border.
1070 func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {1083 func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {
M packages/agent/modes/interactive_resident_subagents.go +1 -1
@@ -64,7 +64,7 @@ func (i *Interactive) reloadResidentChildSession(session *residentChildSession)
6464 return
6565 }
6666 go func() {
67- err := session.ReloadRecent(200)
67+ err := session.ReloadAll(200, i.invalidate)
6868 if session.FinishLoad(err) {
6969 if session.RequestRecentReload() {
7070 i.reloadResidentChildSession(session)
@@ -64,7 +64,7 @@ func (i *Interactive) reloadResidentChildSession(session *residentChildSession)
64 return64 return
65 }65 }
66 go func() {66 go func() {
67- err := session.ReloadRecent(200)67+ err := session.ReloadAll(200, i.invalidate)
68 if session.FinishLoad(err) {68 if session.FinishLoad(err) {
69 if session.RequestRecentReload() {69 if session.RequestRecentReload() {
70 i.reloadResidentChildSession(session)70 i.reloadResidentChildSession(session)
M packages/agent/modes/interactive_terminal_test.go +1 -1
@@ -38,7 +38,7 @@ func TestInteractiveRunClearsVisibleFrameOnExit(t *testing.T) {
3838 t.Fatalf("Run error = %v, want context.Canceled", err)
3939 }
4040
41- cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages +
41+ cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff +
4242 tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff +
4343 tui.ResetCursorColor() + tui.ResetCursorShape() +
4444 tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
@@ -38,7 +38,7 @@ func TestInteractiveRunClearsVisibleFrameOnExit(t *testing.T) {
38 t.Fatalf("Run error = %v, want context.Canceled", err)38 t.Fatalf("Run error = %v, want context.Canceled", err)
39 }39 }
40 40
41- cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages +41+ cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff +
42 tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff +42 tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff +
43 tui.ResetCursorColor() + tui.ResetCursorShape() +43 tui.ResetCursorColor() + tui.ResetCursorShape() +
44 tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor44 tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
M packages/agent/modes/modal_backdrop_test.go +6 -0
@@ -7,6 +7,12 @@ import (
77 "github.com/bnema/zut/packages/tui"
88 )
99
10+func TestFloatingOverlayTitleFallsBackToOverlayIdentity(t *testing.T) {
11+ if got := floatingOverlayTitle("subagent-session"); got != "subagent session" {
12+ t.Fatalf("overlay title = %q, want subagent session", got)
13+ }
14+}
15+
1016 func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) {
1117 lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)})
1218 title, body, removed := floatingDialogBody(lines)
@@ -7,6 +7,12 @@ import (
7 "github.com/bnema/zut/packages/tui"7 "github.com/bnema/zut/packages/tui"
8 )8 )
9 9
10+func TestFloatingOverlayTitleFallsBackToOverlayIdentity(t *testing.T) {
11+ if got := floatingOverlayTitle("subagent-session"); got != "subagent session" {
12+ t.Fatalf("overlay title = %q, want subagent session", got)
13+ }
14+}
15+
10 func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) {16 func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) {
11 lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)})17 lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)})
12 title, body, removed := floatingDialogBody(lines)18 title, body, removed := floatingDialogBody(lines)
M packages/agent/modes/resident_subagent_indicator.go +4 -1
@@ -22,7 +22,10 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
2222 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
2323 lines := make([]string, 0, len(snapshots))
2424 for _, snapshot := range snapshots {
25- if snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning {
25+ // Foreign journals remain discoverable through /subagents, but their
26+ // activity belongs to another zut host and must not occupy this
27+ // session's always-visible editor-adjacent indicator.
28+ if snapshot.OwnedElsewhere || (snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning) {
2629 continue
2730 }
2831 name := sanitizeSessionTreeText(snapshot.Profile)
@@ -22,7 +22,10 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
22 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {22 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
23 lines := make([]string, 0, len(snapshots))23 lines := make([]string, 0, len(snapshots))
24 for _, snapshot := range snapshots {24 for _, snapshot := range snapshots {
25- if snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning {25+ // Foreign journals remain discoverable through /subagents, but their
26+ // activity belongs to another zut host and must not occupy this
27+ // session's always-visible editor-adjacent indicator.
28+ if snapshot.OwnedElsewhere || (snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning) {
26 continue29 continue
27 }30 }
28 name := sanitizeSessionTreeText(snapshot.Profile)31 name := sanitizeSessionTreeText(snapshot.Profile)
M packages/agent/modes/resident_subagent_indicator_test.go +3 -2
@@ -14,14 +14,15 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T
1414 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{
1515 {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},
1616 {ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},
17+ {ID: "foreign-id", Profile: "another process", State: subagents.ResidentRunning, OwnedElsewhere: true, TurnStartedAt: now.Add(-10 * time.Second)},
1718 {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},
1819 }, 80, now)
1920 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
2021 if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {
2122 t.Fatalf("activity lines = %q", got)
2223 }
23- if strings.Contains(got, "finished") {
24- t.Fatalf("activity lines include inactive child: %q", got)
24+ if strings.Contains(got, "finished") || strings.Contains(got, "another process") {
25+ t.Fatalf("activity lines include inactive or foreign child: %q", got)
2526 }
2627 }
2728
@@ -14,14 +14,15 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T
14 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{14 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{
15 {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},15 {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},
16 {ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},16 {ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},
17+ {ID: "foreign-id", Profile: "another process", State: subagents.ResidentRunning, OwnedElsewhere: true, TurnStartedAt: now.Add(-10 * time.Second)},
17 {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},18 {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},
18 }, 80, now)19 }, 80, now)
19 got := strings.Join(plainResidentIndicatorLines(lines), "\n")20 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
20 if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {21 if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {
21 t.Fatalf("activity lines = %q", got)22 t.Fatalf("activity lines = %q", got)
22 }23 }
23- if strings.Contains(got, "finished") {24+ if strings.Contains(got, "finished") || strings.Contains(got, "another process") {
24- t.Fatalf("activity lines include inactive child: %q", got)25+ t.Fatalf("activity lines include inactive or foreign child: %q", got)
25 }26 }
26 }27 }
27 28
M packages/agent/modes/skills_dialog.go +2 -1
@@ -34,7 +34,8 @@ func (d *skillsDialog) Open(s []*skills.Skill) {
3434 func (d *skillsDialog) Close() { d.active = false }
3535
3636 // Active reports whether the dialog is visible.
37-func (d *skillsDialog) Active() bool { return d != nil && d.active }
37+func (d *skillsDialog) Active() bool { return d != nil && d.active }
38+func (d *skillsDialog) Viewing() bool { return d != nil && d.viewing != nil }
3839
3940 // HandleKey advances the dialog state.
4041 func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
@@ -34,7 +34,8 @@ func (d *skillsDialog) Open(s []*skills.Skill) {
34 func (d *skillsDialog) Close() { d.active = false }34 func (d *skillsDialog) Close() { d.active = false }
35 35
36 // Active reports whether the dialog is visible.36 // Active reports whether the dialog is visible.
37-func (d *skillsDialog) Active() bool { return d != nil && d.active }37+func (d *skillsDialog) Active() bool { return d != nil && d.active }
38+func (d *skillsDialog) Viewing() bool { return d != nil && d.viewing != nil }
38 39
39 // HandleKey advances the dialog state.40 // HandleKey advances the dialog state.
40 func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {41 func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
M packages/agent/modes/subagent_session.go +104 -11
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "context"
45 "fmt"
56 "strings"
67 "sync"
@@ -27,14 +28,31 @@ type residentChildSession struct {
2728 // scrollOffset is measured from the rendered live tail. Keeping the
2829 // viewport bottom-relative preserves the same finalized content when an
2930 // older page is prepended or the active turn grows.
30- scrollOffset int
31- unread int
32- lastRows int
33- recentRows int
31+ scrollOffset int
32+ unread int
33+ lastRows int
34+ recentRows int
35+ liveRevision uint64
36+ livePresent bool
37+ cacheWidth int
38+ cacheMessagesRevision uint64
39+ cacheMessageCount int
40+ cacheLiveRevision uint64
41+ cacheLivePresent bool
42+ cacheRows []string
43+ loadCtx context.Context
44+ cancelLoad context.CancelFunc
3445 }
3546
3647 func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {
37- return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > ")}
48+ ctx, cancel := context.WithCancel(context.Background())
49+ return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > "), loadCtx: ctx, cancelLoad: cancel}
50+}
51+
52+func (s *residentChildSession) Close() {
53+ if s != nil && s.cancelLoad != nil {
54+ s.cancelLoad()
55+ }
3856 }
3957
4058 func (s *residentChildSession) LoadRecent(limit int) error {
@@ -60,6 +78,60 @@ func (s *residentChildSession) ReloadRecent(limit int) error {
6078 return s.LoadRecent(limit)
6179 }
6280
81+// LoadAll progressively installs the newest page then prepends every older
82+// page. The callback runs after each durable page so the floating pane grows
83+// naturally while disk history is being reconstructed.
84+func (s *residentChildSession) LoadAll(limit int, progressed func()) error {
85+ if s.loadCtx != nil {
86+ select {
87+ case <-s.loadCtx.Done():
88+ return s.loadCtx.Err()
89+ default:
90+ }
91+ }
92+ if err := s.LoadRecent(limit); err != nil {
93+ return err
94+ }
95+ if progressed != nil {
96+ progressed()
97+ }
98+ for {
99+ if s.loadCtx != nil {
100+ select {
101+ case <-s.loadCtx.Done():
102+ return s.loadCtx.Err()
103+ default:
104+ }
105+ }
106+ s.mu.Lock()
107+ more := s.olderCursor != ""
108+ s.mu.Unlock()
109+ if !more {
110+ return nil
111+ }
112+ if err := s.LoadOlder(limit); err != nil {
113+ return err
114+ }
115+ if progressed != nil {
116+ progressed()
117+ }
118+ }
119+}
120+
121+// ReloadAll rebuilds from one journal generation instead of replacing a
122+// projected-message suffix whose page boundary may not match tool projection.
123+func (s *residentChildSession) ReloadAll(limit int, progressed func()) error {
124+ s.mu.Lock()
125+ s.view.Messages = nil
126+ s.view.MessagesRevision++
127+ s.olderCursor = ""
128+ s.recentRows = 0
129+ // Keep a reader's bottom-relative viewport across a durable reload.
130+ // Render clamps it against the rebuilt rows; a tail follower remains at 0.
131+ s.mu.Unlock()
132+ return s.LoadAll(limit, progressed)
133+}
134+
63135 func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {
64136 if s == nil {
65137 return
@@ -116,8 +188,12 @@ func (s *residentChildSession) refreshLiveLocked() {
116188 s.view.Streaming = ""
117189 s.view.StreamingActive = false
118190 s.view.ToolCalls = nil
191+ s.liveRevision = 0
192+ s.livePresent = false
119193 return
120194 }
195+ s.liveRevision = live.Revision
196+ s.livePresent = true
121197 s.view.Streaming = live.AssistantText
122198 s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""
123199 s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))
@@ -126,6 +202,27 @@ func (s *residentChildSession) refreshLiveLocked() {
126202 }
127203 }
128204
205+func (s *residentChildSession) renderedHistory(width int) []string {
206+ s.mu.Lock()
207+ s.refreshLiveLocked()
208+ if s.cacheWidth == width && s.cacheMessagesRevision == s.view.MessagesRevision && s.cacheMessageCount == len(s.view.Messages) && s.cacheLiveRevision == s.liveRevision && s.cacheLivePresent == s.livePresent && s.cacheRows != nil {
209+ rows := s.cacheRows
210+ s.mu.Unlock()
211+ return rows
212+ }
213+ view := s.view.CloneForRender()
214+ messagesRevision, liveRevision := s.view.MessagesRevision, s.liveRevision
215+ s.mu.Unlock()
216+ rows := view.Build(width)
217+ s.mu.Lock()
218+ if s.view.MessagesRevision == messagesRevision && s.liveRevision == liveRevision {
219+ s.cacheWidth, s.cacheMessagesRevision, s.cacheMessageCount, s.cacheLiveRevision, s.cacheLivePresent, s.cacheRows = width, messagesRevision, len(s.view.Messages), liveRevision, s.livePresent, rows
220+ s.view.AdoptRenderCacheFrom(view)
221+ }
222+ s.mu.Unlock()
223+ return rows
224+}
225+
129226 func (s *residentChildSession) View() *tui.View {
130227 if s == nil {
131228 return nil
@@ -245,10 +342,6 @@ func (s *residentChildSession) Render(width, height int) []string {
245342 if s == nil {
246343 return nil
247344 }
248- view := s.View()
249- if view == nil {
250- return []string{" resident subagent unavailable"}
251- }
252345 s.mu.Lock()
253346 loading, loadErr := s.loading, s.err
254347 var editorLines []string
@@ -275,7 +368,7 @@ func (s *residentChildSession) Render(width, height int) []string {
275368 if loadErr != "" {
276369 lines = append(lines, " history: "+loadErr)
277370 }
278- history := view.Build(width)
371+ history := s.renderedHistory(width)
279372 s.mu.Lock()
280373 if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {
281374 s.scrollOffset += len(history) - s.lastRows
@@ -305,7 +398,7 @@ func (s *residentChildSession) Render(width, height int) []string {
305398 }
306399 lines = append(lines, "")
307400 lines = append(lines, editorLines...)
308- lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp: load older history")
401+ lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp/PgDn: scroll history")
309402 if height <= 0 {
310403 return nil
311404 }
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "strings"6 "strings"
6 "sync"7 "sync"
@@ -27,14 +28,31 @@ type residentChildSession struct {
27 // scrollOffset is measured from the rendered live tail. Keeping the28 // scrollOffset is measured from the rendered live tail. Keeping the
28 // viewport bottom-relative preserves the same finalized content when an29 // viewport bottom-relative preserves the same finalized content when an
29 // older page is prepended or the active turn grows.30 // older page is prepended or the active turn grows.
30- scrollOffset int31+ scrollOffset int
31- unread int32+ unread int
32- lastRows int33+ lastRows int
33- recentRows int34+ recentRows int
35+ liveRevision uint64
36+ livePresent bool
37+ cacheWidth int
38+ cacheMessagesRevision uint64
39+ cacheMessageCount int
40+ cacheLiveRevision uint64
41+ cacheLivePresent bool
42+ cacheRows []string
43+ loadCtx context.Context
44+ cancelLoad context.CancelFunc
34 }45 }
35 46
36 func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {47 func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {
37- return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > ")}48+ ctx, cancel := context.WithCancel(context.Background())
49+ return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > "), loadCtx: ctx, cancelLoad: cancel}
50+}
51+
52+func (s *residentChildSession) Close() {
53+ if s != nil && s.cancelLoad != nil {
54+ s.cancelLoad()
55+ }
38 }56 }
39 57
40 func (s *residentChildSession) LoadRecent(limit int) error {58 func (s *residentChildSession) LoadRecent(limit int) error {
@@ -60,6 +78,60 @@ func (s *residentChildSession) ReloadRecent(limit int) error {
60 return s.LoadRecent(limit)78 return s.LoadRecent(limit)
61 }79 }
62 80
81+// LoadAll progressively installs the newest page then prepends every older
82+// page. The callback runs after each durable page so the floating pane grows
83+// naturally while disk history is being reconstructed.
84+func (s *residentChildSession) LoadAll(limit int, progressed func()) error {
85+ if s.loadCtx != nil {
86+ select {
87+ case <-s.loadCtx.Done():
88+ return s.loadCtx.Err()
89+ default:
90+ }
91+ }
92+ if err := s.LoadRecent(limit); err != nil {
93+ return err
94+ }
95+ if progressed != nil {
96+ progressed()
97+ }
98+ for {
99+ if s.loadCtx != nil {
100+ select {
101+ case <-s.loadCtx.Done():
102+ return s.loadCtx.Err()
103+ default:
104+ }
105+ }
106+ s.mu.Lock()
107+ more := s.olderCursor != ""
108+ s.mu.Unlock()
109+ if !more {
110+ return nil
111+ }
112+ if err := s.LoadOlder(limit); err != nil {
113+ return err
114+ }
115+ if progressed != nil {
116+ progressed()
117+ }
118+ }
119+}
120+
121+// ReloadAll rebuilds from one journal generation instead of replacing a
122+// projected-message suffix whose page boundary may not match tool projection.
123+func (s *residentChildSession) ReloadAll(limit int, progressed func()) error {
124+ s.mu.Lock()
125+ s.view.Messages = nil
126+ s.view.MessagesRevision++
127+ s.olderCursor = ""
128+ s.recentRows = 0
129+ // Keep a reader's bottom-relative viewport across a durable reload.
130+ // Render clamps it against the rebuilt rows; a tail follower remains at 0.
131+ s.mu.Unlock()
132+ return s.LoadAll(limit, progressed)
133+}
134+
63 func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {135 func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {
64 if s == nil {136 if s == nil {
65 return137 return
@@ -116,8 +188,12 @@ func (s *residentChildSession) refreshLiveLocked() {
116 s.view.Streaming = ""188 s.view.Streaming = ""
117 s.view.StreamingActive = false189 s.view.StreamingActive = false
118 s.view.ToolCalls = nil190 s.view.ToolCalls = nil
191+ s.liveRevision = 0
192+ s.livePresent = false
119 return193 return
120 }194 }
195+ s.liveRevision = live.Revision
196+ s.livePresent = true
121 s.view.Streaming = live.AssistantText197 s.view.Streaming = live.AssistantText
122 s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""198 s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""
123 s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))199 s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))
@@ -126,6 +202,27 @@ func (s *residentChildSession) refreshLiveLocked() {
126 }202 }
127 }203 }
128 204
205+func (s *residentChildSession) renderedHistory(width int) []string {
206+ s.mu.Lock()
207+ s.refreshLiveLocked()
208+ if s.cacheWidth == width && s.cacheMessagesRevision == s.view.MessagesRevision && s.cacheMessageCount == len(s.view.Messages) && s.cacheLiveRevision == s.liveRevision && s.cacheLivePresent == s.livePresent && s.cacheRows != nil {
209+ rows := s.cacheRows
210+ s.mu.Unlock()
211+ return rows
212+ }
213+ view := s.view.CloneForRender()
214+ messagesRevision, liveRevision := s.view.MessagesRevision, s.liveRevision
215+ s.mu.Unlock()
216+ rows := view.Build(width)
217+ s.mu.Lock()
218+ if s.view.MessagesRevision == messagesRevision && s.liveRevision == liveRevision {
219+ s.cacheWidth, s.cacheMessagesRevision, s.cacheMessageCount, s.cacheLiveRevision, s.cacheLivePresent, s.cacheRows = width, messagesRevision, len(s.view.Messages), liveRevision, s.livePresent, rows
220+ s.view.AdoptRenderCacheFrom(view)
221+ }
222+ s.mu.Unlock()
223+ return rows
224+}
225+
129 func (s *residentChildSession) View() *tui.View {226 func (s *residentChildSession) View() *tui.View {
130 if s == nil {227 if s == nil {
131 return nil228 return nil
@@ -245,10 +342,6 @@ func (s *residentChildSession) Render(width, height int) []string {
245 if s == nil {342 if s == nil {
246 return nil343 return nil
247 }344 }
248- view := s.View()
249- if view == nil {
250- return []string{" resident subagent unavailable"}
251- }
252 s.mu.Lock()345 s.mu.Lock()
253 loading, loadErr := s.loading, s.err346 loading, loadErr := s.loading, s.err
254 var editorLines []string347 var editorLines []string
@@ -275,7 +368,7 @@ func (s *residentChildSession) Render(width, height int) []string {
275 if loadErr != "" {368 if loadErr != "" {
276 lines = append(lines, " history: "+loadErr)369 lines = append(lines, " history: "+loadErr)
277 }370 }
278- history := view.Build(width)371+ history := s.renderedHistory(width)
279 s.mu.Lock()372 s.mu.Lock()
280 if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {373 if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {
281 s.scrollOffset += len(history) - s.lastRows374 s.scrollOffset += len(history) - s.lastRows
@@ -305,7 +398,7 @@ func (s *residentChildSession) Render(width, height int) []string {
305 }398 }
306 lines = append(lines, "")399 lines = append(lines, "")
307 lines = append(lines, editorLines...)400 lines = append(lines, editorLines...)
308- lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp: load older history")401+ lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp/PgDn: scroll history")
309 if height <= 0 {402 if height <= 0 {
310 return nil403 return nil
311 }404 }
M packages/agent/modes/subagent_session_test.go +26 -0
@@ -196,6 +196,32 @@ func TestResidentChildSessionRenderRespectsRequestedHeight(t *testing.T) {
196196 }
197197 }
198198
199+func TestInteractiveResidentChildMouseWheelScrollsHistory(t *testing.T) {
200+ interactive := NewInteractive(InteractiveConfig{Theme: tui.Dark})
201+ session := newResidentChildSession(nil, "child", tui.Dark)
202+ interactive.residentChildSession = session
203+
204+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelUp})
205+ if session.scrollOffset != 1 {
206+ t.Fatalf("wheel up scroll offset = %d, want 1", session.scrollOffset)
207+ }
208+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelDown})
209+ if session.scrollOffset != 0 {
210+ t.Fatalf("wheel down scroll offset = %d, want 0", session.scrollOffset)
211+ }
212+}
213+
214+func TestInteractiveResidentChildMouseReportingFollowsOverlay(t *testing.T) {
215+ term := &alertTestTerminal{}
216+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
217+ interactive.runCtx = context.Background()
218+ interactive.setResidentChildMouseReporting(true)
219+ interactive.setResidentChildMouseReporting(false)
220+ if got := term.String(); got != tui.SeqMouseOn+tui.SeqMouseOff {
221+ t.Fatalf("mouse reporting sequences = %q", got)
222+ }
223+}
224+
199225 func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
200226 root := t.TempDir()
201227 started := make(chan struct{})
@@ -196,6 +196,32 @@ func TestResidentChildSessionRenderRespectsRequestedHeight(t *testing.T) {
196 }196 }
197 }197 }
198 198
199+func TestInteractiveResidentChildMouseWheelScrollsHistory(t *testing.T) {
200+ interactive := NewInteractive(InteractiveConfig{Theme: tui.Dark})
201+ session := newResidentChildSession(nil, "child", tui.Dark)
202+ interactive.residentChildSession = session
203+
204+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelUp})
205+ if session.scrollOffset != 1 {
206+ t.Fatalf("wheel up scroll offset = %d, want 1", session.scrollOffset)
207+ }
208+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelDown})
209+ if session.scrollOffset != 0 {
210+ t.Fatalf("wheel down scroll offset = %d, want 0", session.scrollOffset)
211+ }
212+}
213+
214+func TestInteractiveResidentChildMouseReportingFollowsOverlay(t *testing.T) {
215+ term := &alertTestTerminal{}
216+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
217+ interactive.runCtx = context.Background()
218+ interactive.setResidentChildMouseReporting(true)
219+ interactive.setResidentChildMouseReporting(false)
220+ if got := term.String(); got != tui.SeqMouseOn+tui.SeqMouseOff {
221+ t.Fatalf("mouse reporting sequences = %q", got)
222+ }
223+}
224+
199 func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {225 func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
200 root := t.TempDir()226 root := t.TempDir()
201 started := make(chan struct{})227 started := make(chan struct{})
M packages/agent/modes/subagents_slash.go +2 -1
@@ -103,10 +103,11 @@ func (i *Interactive) openResidentChildSession(childID string) {
103103 }
104104 i.residentChildSession = session
105105 i.mu.Unlock()
106+ i.setResidentChildMouseReporting(true)
106107 i.invalidate()
107108 if session.BeginLoad() {
108109 go func() {
109- err := session.LoadRecent(200)
110+ err := session.LoadAll(200, i.invalidate)
110111 if session.FinishLoad(err) {
111112 i.reloadResidentChildSession(session)
112113 }
@@ -103,10 +103,11 @@ func (i *Interactive) openResidentChildSession(childID string) {
103 }103 }
104 i.residentChildSession = session104 i.residentChildSession = session
105 i.mu.Unlock()105 i.mu.Unlock()
106+ i.setResidentChildMouseReporting(true)
106 i.invalidate()107 i.invalidate()
107 if session.BeginLoad() {108 if session.BeginLoad() {
108 go func() {109 go func() {
109- err := session.LoadRecent(200)110+ err := session.LoadAll(200, i.invalidate)
110 if session.FinishLoad(err) {111 if session.FinishLoad(err) {
111 i.reloadResidentChildSession(session)112 i.reloadResidentChildSession(session)
112 }113 }
M packages/tui/render.go +15 -4
@@ -31,6 +31,9 @@ type Renderer struct {
3131 // Kitty image pixels are explicitly deleted when their layer disappears.
3232 prevHadImage bool
3333 prevHadKittyImage bool
34+ floatingActive bool
35+ drawingFloating bool
36+ floatingRect FloatingPaneRect
3437
3538 // Main-screen flow renderer state. logLines is the full logical
3639 // buffer (chat + live bottom band) from the previous DrawLog call.
@@ -416,6 +419,10 @@ func truncateToWidth(s string, cols int) string {
416419 }
417420
418421 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
422+ if r.floatingActive && !r.drawingFloating {
423+ r.Invalidate()
424+ r.floatingActive = false
425+ }
419426 if r.cols == 0 || r.rows == 0 {
420427 return
421428 }
@@ -538,8 +545,12 @@ func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int
538545 if r.cols == 0 || r.rows == 0 {
539546 return
540547 }
541- r.Invalidate()
548+ if !r.floatingActive || r.floatingRect != pane.Rect {
549+ r.Invalidate()
550+ }
551+ r.drawingFloating = true
542552 r.Draw(pane.Background, -1, 0)
553+ r.drawingFloating = false
543554
544555 paneHasImage := false
545556 paneHasKittyImage := false
@@ -572,9 +583,9 @@ func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int
572583 _, _ = io.WriteString(r.out, w.String())
573584 r.prevHadImage = r.prevHadImage || paneHasImage
574585 r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage
575- // pane.Lines are painted outside Draw's frame cache, so the next normal
576- // frame must be fully repainted to remove the foreground cells.
577- r.Invalidate()
586+ // Keep the dimmed background cache while this geometry remains active.
587+ r.floatingActive = true
588+ r.floatingRect = pane.Rect
578589 }
579590
580591 // DrawLog renders zut in the terminal's main screen as normal terminal
@@ -31,6 +31,9 @@ type Renderer struct {
31 // Kitty image pixels are explicitly deleted when their layer disappears.31 // Kitty image pixels are explicitly deleted when their layer disappears.
32 prevHadImage bool32 prevHadImage bool
33 prevHadKittyImage bool33 prevHadKittyImage bool
34+ floatingActive bool
35+ drawingFloating bool
36+ floatingRect FloatingPaneRect
34 37
35 // Main-screen flow renderer state. logLines is the full logical38 // Main-screen flow renderer state. logLines is the full logical
36 // buffer (chat + live bottom band) from the previous DrawLog call.39 // buffer (chat + live bottom band) from the previous DrawLog call.
@@ -416,6 +419,10 @@ func truncateToWidth(s string, cols int) string {
416 }419 }
417 420
418 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {421 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
422+ if r.floatingActive && !r.drawingFloating {
423+ r.Invalidate()
424+ r.floatingActive = false
425+ }
419 if r.cols == 0 || r.rows == 0 {426 if r.cols == 0 || r.rows == 0 {
420 return427 return
421 }428 }
@@ -538,8 +545,12 @@ func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int
538 if r.cols == 0 || r.rows == 0 {545 if r.cols == 0 || r.rows == 0 {
539 return546 return
540 }547 }
541- r.Invalidate()548+ if !r.floatingActive || r.floatingRect != pane.Rect {
549+ r.Invalidate()
550+ }
551+ r.drawingFloating = true
542 r.Draw(pane.Background, -1, 0)552 r.Draw(pane.Background, -1, 0)
553+ r.drawingFloating = false
543 554
544 paneHasImage := false555 paneHasImage := false
545 paneHasKittyImage := false556 paneHasKittyImage := false
@@ -572,9 +583,9 @@ func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int
572 _, _ = io.WriteString(r.out, w.String())583 _, _ = io.WriteString(r.out, w.String())
573 r.prevHadImage = r.prevHadImage || paneHasImage584 r.prevHadImage = r.prevHadImage || paneHasImage
574 r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage585 r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage
575- // pane.Lines are painted outside Draw's frame cache, so the next normal586+ // Keep the dimmed background cache while this geometry remains active.
576- // frame must be fully repainted to remove the foreground cells.587+ r.floatingActive = true
577- r.Invalidate()588+ r.floatingRect = pane.Rect
578 }589 }
579 590
580 // DrawLog renders zut in the terminal's main screen as normal terminal591 // DrawLog renders zut in the terminal's main screen as normal terminal

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