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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 772 | 772 | sessionDialog *sessionDialog | |
| 773 | 773 | residentSubagentsDialog *residentSubagentsDialog | |
| 774 | 774 | residentChildSession *residentChildSession | |
| 775 | + | residentChildMouse bool | |
| 775 | 776 | jumpDialog *jumpDialog | |
| 776 | 777 | btwDialog *btwDialog | |
| 777 | 778 | skillsDialog *skillsDialog | |
| @@ -1063,6 +1064,28 @@ func (i *Interactive) markCtrlCExit() { | |||
| 1063 | 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 | 1089 | // Run blocks until the user quits. | |
| 1067 | 1090 | func (i *Interactive) Run(ctx context.Context) error { | |
| 1068 | 1091 | i.mu.Lock() | |
| @@ -1103,7 +1126,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1103 | 1126 | // Erase the live frame and place the cursor deterministically before any | |
| 1104 | 1127 | // exit summary or shell prompt is written. Do not erase scrollback: users | |
| 1105 | 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 | 1130 | i.applyInputCursorColor() | |
| 1108 | 1131 | ||
| 1109 | 1132 | // Streaming pacer: drains buffered text deltas at a steady rate | |
| @@ -772,6 +772,7 @@ type Interactive struct { | |||||
| 772 | sessionDialog *sessionDialog | 772 | sessionDialog *sessionDialog | ||
| 773 | residentSubagentsDialog *residentSubagentsDialog | 773 | residentSubagentsDialog *residentSubagentsDialog | ||
| 774 | residentChildSession *residentChildSession | 774 | residentChildSession *residentChildSession | ||
| 775 | + | residentChildMouse bool | |||
| 775 | jumpDialog *jumpDialog | 776 | jumpDialog *jumpDialog | ||
| 776 | btwDialog *btwDialog | 777 | btwDialog *btwDialog | ||
| 777 | skillsDialog *skillsDialog | 778 | 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 any | 1126 | // Erase the live frame and place the cursor deterministically before any | ||
| 1104 | // exit summary or shell prompt is written. Do not erase scrollback: users | 1127 | // 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 rate | 1132 | // 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) { | |||
| 275 | 275 | i.residentChildSession = nil | |
| 276 | 276 | } | |
| 277 | 277 | i.mu.Unlock() | |
| 278 | + | residentSession.Close() | |
| 279 | + | i.setResidentChildMouseReporting(false) | |
| 278 | 280 | 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) | |
| 289 | 282 | case tui.KeyPageDown: | |
| 290 | - | residentSession.FollowTail() | |
| 291 | - | case tui.KeyUp: | |
| 283 | + | residentSession.Scroll(-10) | |
| 284 | + | case tui.KeyUp, tui.KeyMouseWheelUp: | |
| 292 | 285 | residentSession.Scroll(1) | |
| 293 | - | case tui.KeyDown: | |
| 286 | + | case tui.KeyDown, tui.KeyMouseWheelDown: | |
| 294 | 287 | residentSession.Scroll(-1) | |
| 295 | 288 | default: | |
| 296 | 289 | session := residentSession | |
| @@ -275,22 +275,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 275 | i.residentChildSession = nil | 275 | 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 := residentSession | 281 | + | 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 := residentSession | 289 | session := residentSession | ||
M packages/agent/modes/interactive_rendering.go +15 -2
| @@ -540,6 +540,9 @@ func (i *Interactive) redraw() { | |||
| 540 | 540 | dialog = i.btwDialog.Render(i.cfg.Theme, dialogWidth) | |
| 541 | 541 | case i.skillsDialog.Active(): | |
| 542 | 542 | dialogID = "skills" | |
| 543 | + | if i.skillsDialog.Viewing() { | |
| 544 | + | dialogID = "skills-body" | |
| 545 | + | } | |
| 543 | 546 | dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth) | |
| 544 | 547 | case i.changelogDialog.Active(): | |
| 545 | 548 | dialogID = "changelog" | |
| @@ -561,11 +564,15 @@ func (i *Interactive) redraw() { | |||
| 561 | 564 | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | |
| 562 | 565 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) | |
| 563 | 566 | } | |
| 564 | - | dialogTitle := "" | |
| 567 | + | dialogTitle := floatingOverlayTitle(dialogID) | |
| 565 | 568 | dialogRemovedTopRows := 0 | |
| 566 | 569 | if len(dialog) > 0 { | |
| 567 | 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 | 577 | dialogFocusRow := -1 | |
| 571 | 578 | for row, line := range dialog { | |
| @@ -1065,6 +1072,12 @@ func (i *Interactive) redraw() { | |||
| 1065 | 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 | 1081 | // floatingDialogBody removes a legacy dialog's rule chrome and returns its | |
| 1069 | 1082 | // title for the shared floating-pane border. | |
| 1070 | 1083 | 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 := 0 | 568 | 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 := -1 | 577 | 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 its | 1081 | // 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) | |||
| 64 | 64 | return | |
| 65 | 65 | } | |
| 66 | 66 | go func() { | |
| 67 | - | err := session.ReloadRecent(200) | |
| 67 | + | err := session.ReloadAll(200, i.invalidate) | |
| 68 | 68 | if session.FinishLoad(err) { | |
| 69 | 69 | if session.RequestRecentReload() { | |
| 70 | 70 | i.reloadResidentChildSession(session) | |
| @@ -64,7 +64,7 @@ func (i *Interactive) reloadResidentChildSession(session *residentChildSession) | |||||
| 64 | return | 64 | 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) { | |||
| 38 | 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 | 42 | tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + | |
| 43 | 43 | tui.ResetCursorColor() + tui.ResetCursorShape() + | |
| 44 | 44 | 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.SeqShowCursor | 44 | tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor | ||
M packages/agent/modes/modal_backdrop_test.go +6 -0
| @@ -7,6 +7,12 @@ import ( | |||
| 7 | 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 | 16 | func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) { | |
| 11 | 17 | lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)}) | |
| 12 | 18 | 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( | |||
| 22 | 22 | func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string { | |
| 23 | 23 | lines := make([]string, 0, len(snapshots)) | |
| 24 | 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 | 29 | continue | |
| 27 | 30 | } | |
| 28 | 31 | 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 | continue | 29 | 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 | |||
| 14 | 14 | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{ | |
| 15 | 15 | {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true}, | |
| 16 | 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 | 18 | {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle}, | |
| 18 | 19 | }, 80, now) | |
| 19 | 20 | got := strings.Join(plainResidentIndicatorLines(lines), "\n") | |
| 20 | 21 | if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") { | |
| 21 | 22 | t.Fatalf("activity lines = %q", got) | |
| 22 | 23 | } | |
| 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) | |
| 25 | 26 | } | |
| 26 | 27 | } | |
| 27 | 28 | ||
| @@ -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) { | |||
| 34 | 34 | func (d *skillsDialog) Close() { d.active = false } | |
| 35 | 35 | ||
| 36 | 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 | 40 | // HandleKey advances the dialog state. | |
| 40 | 41 | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "sync" | |
| @@ -27,14 +28,31 @@ type residentChildSession struct { | |||
| 27 | 28 | // scrollOffset is measured from the rendered live tail. Keeping the | |
| 28 | 29 | // viewport bottom-relative preserves the same finalized content when an | |
| 29 | 30 | // 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 | |
| 34 | 45 | } | |
| 35 | 46 | ||
| 36 | 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 | 58 | func (s *residentChildSession) LoadRecent(limit int) error { | |
| @@ -60,6 +78,60 @@ func (s *residentChildSession) ReloadRecent(limit int) error { | |||
| 60 | 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 | 135 | func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) { | |
| 64 | 136 | if s == nil { | |
| 65 | 137 | return | |
| @@ -116,8 +188,12 @@ func (s *residentChildSession) refreshLiveLocked() { | |||
| 116 | 188 | s.view.Streaming = "" | |
| 117 | 189 | s.view.StreamingActive = false | |
| 118 | 190 | s.view.ToolCalls = nil | |
| 191 | + | s.liveRevision = 0 | |
| 192 | + | s.livePresent = false | |
| 119 | 193 | return | |
| 120 | 194 | } | |
| 195 | + | s.liveRevision = live.Revision | |
| 196 | + | s.livePresent = true | |
| 121 | 197 | s.view.Streaming = live.AssistantText | |
| 122 | 198 | s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != "" | |
| 123 | 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 | 226 | func (s *residentChildSession) View() *tui.View { | |
| 130 | 227 | if s == nil { | |
| 131 | 228 | return nil | |
| @@ -245,10 +342,6 @@ func (s *residentChildSession) Render(width, height int) []string { | |||
| 245 | 342 | if s == nil { | |
| 246 | 343 | return nil | |
| 247 | 344 | } | |
| 248 | - | view := s.View() | |
| 249 | - | if view == nil { | |
| 250 | - | return []string{" resident subagent unavailable"} | |
| 251 | - | } | |
| 252 | 345 | s.mu.Lock() | |
| 253 | 346 | loading, loadErr := s.loading, s.err | |
| 254 | 347 | var editorLines []string | |
| @@ -275,7 +368,7 @@ func (s *residentChildSession) Render(width, height int) []string { | |||
| 275 | 368 | if loadErr != "" { | |
| 276 | 369 | lines = append(lines, " history: "+loadErr) | |
| 277 | 370 | } | |
| 278 | - | history := view.Build(width) | |
| 371 | + | history := s.renderedHistory(width) | |
| 279 | 372 | s.mu.Lock() | |
| 280 | 373 | if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 { | |
| 281 | 374 | s.scrollOffset += len(history) - s.lastRows | |
| @@ -305,7 +398,7 @@ func (s *residentChildSession) Render(width, height int) []string { | |||
| 305 | 398 | } | |
| 306 | 399 | lines = append(lines, "") | |
| 307 | 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 | 402 | if height <= 0 { | |
| 310 | 403 | return nil | |
| 311 | 404 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package modes | 1 | 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 the | 28 | // scrollOffset is measured from the rendered live tail. Keeping the | ||
| 28 | // viewport bottom-relative preserves the same finalized content when an | 29 | // 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 int | 31 | + | scrollOffset int |
| 31 | - | unread int | 32 | + | unread int |
| 32 | - | lastRows int | 33 | + | lastRows int |
| 33 | - | recentRows 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 | |||
| 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 | return | 137 | return | ||
| @@ -116,8 +188,12 @@ func (s *residentChildSession) refreshLiveLocked() { | |||||
| 116 | s.view.Streaming = "" | 188 | s.view.Streaming = "" | ||
| 117 | s.view.StreamingActive = false | 189 | s.view.StreamingActive = false | ||
| 118 | s.view.ToolCalls = nil | 190 | s.view.ToolCalls = nil | ||
| 191 | + | s.liveRevision = 0 | |||
| 192 | + | s.livePresent = false | |||
| 119 | return | 193 | return | ||
| 120 | } | 194 | } | ||
| 195 | + | s.liveRevision = live.Revision | |||
| 196 | + | s.livePresent = true | |||
| 121 | s.view.Streaming = live.AssistantText | 197 | 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 nil | 228 | return nil | ||
| @@ -245,10 +342,6 @@ func (s *residentChildSession) Render(width, height int) []string { | |||||
| 245 | if s == nil { | 342 | if s == nil { | ||
| 246 | return nil | 343 | 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.err | 346 | loading, loadErr := s.loading, s.err | ||
| 254 | var editorLines []string | 347 | 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.lastRows | 374 | 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 nil | 403 | return nil | ||
| 311 | } | 404 | } | ||
M packages/agent/modes/subagent_session_test.go +26 -0
| @@ -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 | 225 | func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) { | |
| 200 | 226 | root := t.TempDir() | |
| 201 | 227 | 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) { | |||
| 103 | 103 | } | |
| 104 | 104 | i.residentChildSession = session | |
| 105 | 105 | i.mu.Unlock() | |
| 106 | + | i.setResidentChildMouseReporting(true) | |
| 106 | 107 | i.invalidate() | |
| 107 | 108 | if session.BeginLoad() { | |
| 108 | 109 | go func() { | |
| 109 | - | err := session.LoadRecent(200) | |
| 110 | + | err := session.LoadAll(200, i.invalidate) | |
| 110 | 111 | if session.FinishLoad(err) { | |
| 111 | 112 | i.reloadResidentChildSession(session) | |
| 112 | 113 | } | |
| @@ -103,10 +103,11 @@ func (i *Interactive) openResidentChildSession(childID string) { | |||||
| 103 | } | 103 | } | ||
| 104 | i.residentChildSession = session | 104 | 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 { | |||
| 31 | 31 | // Kitty image pixels are explicitly deleted when their layer disappears. | |
| 32 | 32 | prevHadImage bool | |
| 33 | 33 | prevHadKittyImage bool | |
| 34 | + | floatingActive bool | |
| 35 | + | drawingFloating bool | |
| 36 | + | floatingRect FloatingPaneRect | |
| 34 | 37 | ||
| 35 | 38 | // Main-screen flow renderer state. logLines is the full logical | |
| 36 | 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 | 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 | 426 | if r.cols == 0 || r.rows == 0 { | |
| 420 | 427 | return | |
| 421 | 428 | } | |
| @@ -538,8 +545,12 @@ func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int | |||
| 538 | 545 | if r.cols == 0 || r.rows == 0 { | |
| 539 | 546 | return | |
| 540 | 547 | } | |
| 541 | - | r.Invalidate() | |
| 548 | + | if !r.floatingActive || r.floatingRect != pane.Rect { | |
| 549 | + | r.Invalidate() | |
| 550 | + | } | |
| 551 | + | r.drawingFloating = true | |
| 542 | 552 | r.Draw(pane.Background, -1, 0) | |
| 553 | + | r.drawingFloating = false | |
| 543 | 554 | ||
| 544 | 555 | paneHasImage := false | |
| 545 | 556 | paneHasKittyImage := false | |
| @@ -572,9 +583,9 @@ func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int | |||
| 572 | 583 | _, _ = io.WriteString(r.out, w.String()) | |
| 573 | 584 | r.prevHadImage = r.prevHadImage || paneHasImage | |
| 574 | 585 | 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 | |
| 578 | 589 | } | |
| 579 | 590 | ||
| 580 | 591 | // 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 bool | 32 | prevHadImage bool | ||
| 33 | prevHadKittyImage bool | 33 | prevHadKittyImage bool | ||
| 34 | + | floatingActive bool | |||
| 35 | + | drawingFloating bool | |||
| 36 | + | floatingRect FloatingPaneRect | |||
| 34 | 37 | ||||
| 35 | // Main-screen flow renderer state. logLines is the full logical | 38 | // 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 | return | 427 | 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 | return | 546 | 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 := false | 555 | paneHasImage := false | ||
| 545 | paneHasKittyImage := false | 556 | 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 || paneHasImage | 584 | r.prevHadImage = r.prevHadImage || paneHasImage | ||
| 574 | r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage | 585 | r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage | ||
| 575 | - | // pane.Lines are painted outside Draw's frame cache, so the next normal | 586 | + | // 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 terminal | 591 | // DrawLog renders zut in the terminal's main screen as normal terminal | ||