Merge pull request #135 from bnema/feat/floating-pane
feat(tui): add floating panes
18 files · +911 -128
M README.md +1 -1
| @@ -260,7 +260,7 @@ When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspac | |||
| 260 | 260 | ||
| 261 | 261 | ## Modes | |
| 262 | 262 | ||
| 263 | - | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | |
| 263 | + | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. Independent views such as `/settings`, `/subagents`, pickers, and confirmations open in a focused floating pane while the live main view remains visible and dimmed behind it. On terminals narrower than 80 columns, the pane becomes a full-width bottom drawer. | |
| 264 | 264 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | |
| 265 | 265 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | |
| 266 | 266 | - **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`. | |
| @@ -260,7 +260,7 @@ When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspac | |||||
| 260 | 260 | ||||
| 261 | ## Modes | 261 | ## Modes | ||
| 262 | 262 | ||||
| 263 | - | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | 263 | + | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. Independent views such as `/settings`, `/subagents`, pickers, and confirmations open in a focused floating pane while the live main view remains visible and dimmed behind it. On terminals narrower than 80 columns, the pane becomes a full-width bottom drawer. |
| 264 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | 264 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | ||
| 265 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | 265 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | ||
| 266 | - **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`. | 266 | - **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`. | ||
M packages/agent/modes/btw_dialog_test.go +3 -2
| @@ -187,7 +187,8 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||
| 187 | 187 | } | |
| 188 | 188 | d.mu.Unlock() | |
| 189 | 189 | ||
| 190 | - | rows := d.Render(tui.Theme{}, 80) | |
| 190 | + | const paneWidth = 62 | |
| 191 | + | rows := d.Render(tui.Theme{}, paneWidth) | |
| 191 | 192 | rendered := strings.Join(rows, "\n") | |
| 192 | 193 | if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") { | |
| 193 | 194 | t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered) | |
| @@ -202,7 +203,7 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||
| 202 | 203 | if toolRow < 1 || rows[toolRow-1] != "" { | |
| 203 | 204 | t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered) | |
| 204 | 205 | } | |
| 205 | - | if cursorRow, _ := d.CursorPos(80); cursorRow != len(rows)-3 { | |
| 206 | + | if cursorRow, _ := d.CursorPos(paneWidth); cursorRow != len(rows)-3 { | |
| 206 | 207 | t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3) | |
| 207 | 208 | } | |
| 208 | 209 | } | |
| @@ -187,7 +187,8 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||||
| 187 | } | 187 | } | ||
| 188 | d.mu.Unlock() | 188 | d.mu.Unlock() | ||
| 189 | 189 | ||||
| 190 | - | rows := d.Render(tui.Theme{}, 80) | 190 | + | const paneWidth = 62 |
| 191 | + | rows := d.Render(tui.Theme{}, paneWidth) | |||
| 191 | rendered := strings.Join(rows, "\n") | 192 | rendered := strings.Join(rows, "\n") | ||
| 192 | if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") { | 193 | if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") { | ||
| 193 | t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered) | 194 | t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered) | ||
| @@ -202,7 +203,7 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||||
| 202 | if toolRow < 1 || rows[toolRow-1] != "" { | 203 | if toolRow < 1 || rows[toolRow-1] != "" { | ||
| 203 | t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered) | 204 | t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered) | ||
| 204 | } | 205 | } | ||
| 205 | - | if cursorRow, _ := d.CursorPos(80); cursorRow != len(rows)-3 { | 206 | + | if cursorRow, _ := d.CursorPos(paneWidth); cursorRow != len(rows)-3 { |
| 206 | t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3) | 207 | t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3) | ||
| 207 | } | 208 | } | ||
| 208 | } | 209 | } | ||
M packages/agent/modes/interactive.go +25 -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 | |
| @@ -780,6 +781,7 @@ type Interactive struct { | |||
| 780 | 781 | logoutDialog *logoutDialog | |
| 781 | 782 | telegramDialog *telegramDialog | |
| 782 | 783 | settingsDialog *settingsDialog | |
| 784 | + | floatingPane tui.FloatingPane | |
| 783 | 785 | quickModelAssign int | |
| 784 | 786 | telegramBridge *telegram.Bridge | |
| 785 | 787 | sessionOpsDialog *sessionOpsDialog | |
| @@ -1062,6 +1064,28 @@ func (i *Interactive) markCtrlCExit() { | |||
| 1062 | 1064 | i.mu.Unlock() | |
| 1063 | 1065 | } | |
| 1064 | 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 | + | ||
| 1065 | 1089 | // Run blocks until the user quits. | |
| 1066 | 1090 | func (i *Interactive) Run(ctx context.Context) error { | |
| 1067 | 1091 | i.mu.Lock() | |
| @@ -1102,7 +1126,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1102 | 1126 | // Erase the live frame and place the cursor deterministically before any | |
| 1103 | 1127 | // exit summary or shell prompt is written. Do not erase scrollback: users | |
| 1104 | 1128 | // should still be able to review the session after closing zut. | |
| 1105 | - | 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)) | |
| 1106 | 1130 | i.applyInputCursorColor() | |
| 1107 | 1131 | ||
| 1108 | 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 | ||
| @@ -780,6 +781,7 @@ type Interactive struct { | |||||
| 780 | logoutDialog *logoutDialog | 781 | logoutDialog *logoutDialog | ||
| 781 | telegramDialog *telegramDialog | 782 | telegramDialog *telegramDialog | ||
| 782 | settingsDialog *settingsDialog | 783 | settingsDialog *settingsDialog | ||
| 784 | + | floatingPane tui.FloatingPane | |||
| 783 | quickModelAssign int | 785 | quickModelAssign int | ||
| 784 | telegramBridge *telegram.Bridge | 786 | telegramBridge *telegram.Bridge | ||
| 785 | sessionOpsDialog *sessionOpsDialog | 787 | sessionOpsDialog *sessionOpsDialog | ||
| @@ -1062,6 +1064,28 @@ func (i *Interactive) markCtrlCExit() { | |||||
| 1062 | i.mu.Unlock() | 1064 | i.mu.Unlock() | ||
| 1063 | } | 1065 | } | ||
| 1064 | 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 | + | ||||
| 1065 | // Run blocks until the user quits. | 1089 | // Run blocks until the user quits. | ||
| 1066 | func (i *Interactive) Run(ctx context.Context) error { | 1090 | func (i *Interactive) Run(ctx context.Context) error { | ||
| 1067 | i.mu.Lock() | 1091 | i.mu.Lock() | ||
| @@ -1102,7 +1126,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1102 | // Erase the live frame and place the cursor deterministically before any | 1126 | // Erase the live frame and place the cursor deterministically before any | ||
| 1103 | // 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 | ||
| 1104 | // should still be able to review the session after closing zut. | 1128 | // should still be able to review the session after closing zut. | ||
| 1105 | - | 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)) |
| 1106 | i.applyInputCursorColor() | 1130 | i.applyInputCursorColor() | ||
| 1107 | 1131 | ||||
| 1108 | // 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 +13 -12
| @@ -275,22 +275,23 @@ 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() | |
| 283 | + | residentSession.Scroll(-10) | |
| 291 | 284 | case tui.KeyUp: | |
| 292 | - | residentSession.Scroll(1) | |
| 285 | + | if !residentSession.HandleNavigation(k) { | |
| 286 | + | residentSession.Scroll(1) | |
| 287 | + | } | |
| 293 | 288 | case tui.KeyDown: | |
| 289 | + | if !residentSession.HandleNavigation(k) { | |
| 290 | + | residentSession.Scroll(-1) | |
| 291 | + | } | |
| 292 | + | case tui.KeyMouseWheelUp: | |
| 293 | + | residentSession.Scroll(1) | |
| 294 | + | case tui.KeyMouseWheelDown: | |
| 294 | 295 | residentSession.Scroll(-1) | |
| 295 | 296 | default: | |
| 296 | 297 | session := residentSession | |
| @@ -275,22 +275,23 @@ 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: | ||
| 292 | - | residentSession.Scroll(1) | 285 | + | if !residentSession.HandleNavigation(k) { |
| 286 | + | residentSession.Scroll(1) | |||
| 287 | + | } | |||
| 293 | case tui.KeyDown: | 288 | case tui.KeyDown: | ||
| 289 | + | if !residentSession.HandleNavigation(k) { | |||
| 290 | + | residentSession.Scroll(-1) | |||
| 291 | + | } | |||
| 292 | + | case tui.KeyMouseWheelUp: | |||
| 293 | + | residentSession.Scroll(1) | |||
| 294 | + | case tui.KeyMouseWheelDown: | |||
| 294 | residentSession.Scroll(-1) | 295 | residentSession.Scroll(-1) | ||
| 295 | default: | 296 | default: | ||
| 296 | session := residentSession | 297 | session := residentSession | ||
M packages/agent/modes/interactive_rendering.go +152 -82
| @@ -492,75 +492,94 @@ func (i *Interactive) redraw() { | |||
| 492 | 492 | return | |
| 493 | 493 | } | |
| 494 | 494 | ||
| 495 | - | // Dialogs (login or model picker) render between chat and the editor. | |
| 495 | + | // Independent views render into a floating pane over the live transcript. | |
| 496 | + | // The pane owns its geometry and viewport; each view retains its own local | |
| 497 | + | // selection, editor, and asynchronous state. | |
| 498 | + | paneMax := tui.FloatingPaneMaxRect(cols, rows) | |
| 499 | + | dialogWidth := paneMax.ContentWidth() | |
| 496 | 500 | var dialog []string | |
| 501 | + | var dialogID string | |
| 497 | 502 | switch { | |
| 498 | 503 | case i.dialog.Active(): | |
| 499 | - | dialog = i.dialog.Render(i.cfg.Theme, mainCols) | |
| 504 | + | dialogID = "login" | |
| 505 | + | dialog = i.dialog.Render(i.cfg.Theme, dialogWidth) | |
| 500 | 506 | case i.modelDialog.Active(): | |
| 501 | - | dialog = i.modelDialog.Render(i.cfg.Theme, mainCols) | |
| 507 | + | dialogID = "model" | |
| 508 | + | dialog = i.modelDialog.Render(i.cfg.Theme, dialogWidth) | |
| 502 | 509 | case i.llamaDialog.Active(): | |
| 503 | - | dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols) | |
| 510 | + | dialogID = "llama" | |
| 511 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, dialogWidth) | |
| 504 | 512 | case i.rescueDialog.Active(): | |
| 505 | - | dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols) | |
| 513 | + | dialogID = "rescue" | |
| 514 | + | dialog = i.rescueDialog.Render(i.cfg.Theme, dialogWidth) | |
| 506 | 515 | case i.sessionDialog.Active(): | |
| 507 | - | // Reserve rows for the editor (~3), status line (1-2), | |
| 508 | - | // dialog chrome (header + hint + rule + indicators, ~5), | |
| 509 | - | // and leave the remainder for session rows. Minimum of 3 | |
| 510 | - | // rows so even a very small terminal shows something. | |
| 511 | - | _, rows := i.cfg.Terminal.Size() | |
| 512 | - | avail := rows - 12 | |
| 513 | - | if avail < 3 { | |
| 514 | - | avail = 3 | |
| 515 | - | } | |
| 516 | - | i.sessionDialog.MaxRows = avail | |
| 517 | - | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | |
| 516 | + | dialogID = "sessions" | |
| 517 | + | i.sessionDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | |
| 518 | + | dialog = i.sessionDialog.Render(i.cfg.Theme, dialogWidth) | |
| 518 | 519 | case i.residentSubagentsDialog.Active(): | |
| 519 | - | _, terminalRows := i.cfg.Terminal.Size() | |
| 520 | - | dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows) | |
| 520 | + | dialogID = "subagents" | |
| 521 | + | dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, dialogWidth, paneMax.ContentHeight()) | |
| 521 | 522 | case i.residentChildSession != nil: | |
| 522 | - | _, terminalRows := i.cfg.Terminal.Size() | |
| 523 | - | dialog = i.residentChildSession.Render(mainCols, terminalRows) | |
| 523 | + | dialogID = "subagent-session" | |
| 524 | + | dialog = i.residentChildSession.Render(dialogWidth, paneMax.ContentHeight()) | |
| 524 | 525 | case i.jumpDialog.Active(): | |
| 525 | - | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | |
| 526 | + | dialogID = "jump" | |
| 527 | + | dialog = i.jumpDialog.Render(i.cfg.Theme, dialogWidth) | |
| 526 | 528 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | |
| 527 | - | dialog = i.extPanel.Render(i.cfg.Theme, mainCols) | |
| 529 | + | dialogID = "extension-panel" | |
| 530 | + | dialog = i.extPanel.Render(i.cfg.Theme, dialogWidth) | |
| 528 | 531 | case i.confirmDialog.Active(): | |
| 532 | + | dialogID = "confirmation" | |
| 529 | 533 | if i.btwDialog.Active() { | |
| 530 | - | // Keep the side-chat transcript and its live tool preview visible | |
| 531 | - | // above confirmation, matching how the main transcript remains | |
| 532 | - | // visible while --no-yolo waits for a decision. | |
| 533 | - | dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog) | |
| 534 | + | dialog = renderBtwConfirmation(i.cfg.Theme, dialogWidth, i.btwDialog, i.confirmDialog) | |
| 534 | 535 | } else { | |
| 535 | - | dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols) | |
| 536 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, dialogWidth) | |
| 536 | 537 | } | |
| 537 | 538 | case i.btwDialog.Active(): | |
| 538 | - | dialog = i.btwDialog.Render(i.cfg.Theme, mainCols) | |
| 539 | + | dialogID = "side-chat" | |
| 540 | + | dialog = i.btwDialog.Render(i.cfg.Theme, dialogWidth) | |
| 539 | 541 | case i.skillsDialog.Active(): | |
| 540 | - | dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols) | |
| 542 | + | dialogID = "skills" | |
| 543 | + | if i.skillsDialog.Viewing() { | |
| 544 | + | dialogID = "skills-body" | |
| 545 | + | } | |
| 546 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth) | |
| 541 | 547 | case i.changelogDialog.Active(): | |
| 542 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) | |
| 548 | + | dialogID = "changelog" | |
| 549 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, dialogWidth) | |
| 543 | 550 | case i.logoutDialog.Active(): | |
| 544 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) | |
| 551 | + | dialogID = "logout" | |
| 552 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, dialogWidth) | |
| 545 | 553 | case i.telegramDialog.Active(): | |
| 546 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) | |
| 554 | + | dialogID = "telegram" | |
| 555 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, dialogWidth) | |
| 547 | 556 | case i.settingsDialog.Active(): | |
| 548 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) | |
| 557 | + | dialogID = "settings" | |
| 558 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, dialogWidth) | |
| 549 | 559 | case i.sessionOpsDialog.Active(): | |
| 550 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) | |
| 560 | + | dialogID = "session-ops" | |
| 561 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, dialogWidth) | |
| 551 | 562 | case i.sessionTreeDialog.Active(): | |
| 552 | - | // Reserve rows for the editor, status, and tree chrome while using | |
| 553 | - | // the same budget for rendering and PageUp/PageDown movement. | |
| 554 | - | _, rows := i.cfg.Terminal.Size() | |
| 555 | - | avail := rows - 12 | |
| 556 | - | if avail < 3 { | |
| 557 | - | avail = 3 | |
| 558 | - | } | |
| 559 | - | i.sessionTreeDialog.MaxRows = avail | |
| 560 | - | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols) | |
| 563 | + | dialogID = "session-tree" | |
| 564 | + | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | |
| 565 | + | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) | |
| 561 | 566 | } | |
| 567 | + | dialogTitle := floatingOverlayTitle(dialogID) | |
| 568 | + | dialogRemovedTopRows := 0 | |
| 562 | 569 | if len(dialog) > 0 { | |
| 563 | 570 | dialog = padDialogFrame(dialog) | |
| 571 | + | frameTitle, body, removedTopRows := floatingDialogBody(dialog) | |
| 572 | + | if frameTitle != "" { | |
| 573 | + | dialogTitle = frameTitle | |
| 574 | + | } | |
| 575 | + | dialog, dialogRemovedTopRows = body, removedTopRows | |
| 576 | + | } | |
| 577 | + | dialogFocusRow := -1 | |
| 578 | + | for row, line := range dialog { | |
| 579 | + | if strings.Contains(line, i.cfg.Theme.SelectionStyle()) { | |
| 580 | + | dialogFocusRow = row | |
| 581 | + | break | |
| 582 | + | } | |
| 564 | 583 | } | |
| 565 | 584 | modalBackdrop := len(dialog) > 0 | |
| 566 | 585 | ||
| @@ -695,10 +714,12 @@ func (i *Interactive) redraw() { | |||
| 695 | 714 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | |
| 696 | 715 | } | |
| 697 | 716 | edLines, curR, curC := i.ed.Render(mainCols) | |
| 698 | - | dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |
| 717 | + | // Floating independent views no longer replace the main bottom band: the | |
| 718 | + | // underlying chat, status, and editor keep updating behind the pane. | |
| 719 | + | residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |
| 699 | 720 | var allResidentSubagentLines []string | |
| 700 | 721 | residentSubagentHidden := 0 | |
| 701 | - | if !dashboardActive && i.cfg.ResidentManager != nil { | |
| 722 | + | if !residentViewActive && i.cfg.ResidentManager != nil { | |
| 702 | 723 | const residentIndicatorSnapshotLimit = 8 | |
| 703 | 724 | snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit) | |
| 704 | 725 | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | |
| @@ -752,17 +773,7 @@ func (i *Interactive) redraw() { | |||
| 752 | 773 | // blanks so spacing stays consistent whether or not a dialog or | |
| 753 | 774 | // popup is showing. | |
| 754 | 775 | composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) { | |
| 755 | - | bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) | |
| 756 | - | inputStartRow = -1 | |
| 757 | - | if len(dialog) > 0 { | |
| 758 | - | bottom = append(bottom, "") | |
| 759 | - | } | |
| 760 | - | bottom = append(bottom, dialog...) | |
| 761 | - | // The resident child views own the bottom of the screen while active. | |
| 762 | - | if dashboardActive { | |
| 763 | - | return bottom, inputStartRow | |
| 764 | - | } | |
| 765 | - | ||
| 776 | + | bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) | |
| 766 | 777 | bottom = append(bottom, suggest...) | |
| 767 | 778 | bottom = append(bottom, queue...) | |
| 768 | 779 | lineInput := inputStyle == tui.InputStyleLines | |
| @@ -814,7 +825,7 @@ func (i *Interactive) redraw() { | |||
| 814 | 825 | var residentSubagentLines []string | |
| 815 | 826 | var bottom []string | |
| 816 | 827 | var inputStartRow int | |
| 817 | - | if !dashboardActive && len(allResidentSubagentLines) > 0 { | |
| 828 | + | if len(allResidentSubagentLines) > 0 { | |
| 818 | 829 | for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- { | |
| 819 | 830 | candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols) | |
| 820 | 831 | candidateBottom, _ := composeBottom(candidate) | |
| @@ -969,39 +980,24 @@ func (i *Interactive) redraw() { | |||
| 969 | 980 | } | |
| 970 | 981 | } | |
| 971 | 982 | ||
| 972 | - | // A dialog is the foreground layer. Dim every other visible surface, | |
| 973 | - | // including the main input beneath it, without changing the dialog's | |
| 974 | - | // own styling. Keep the source slices untouched because chat is cached. | |
| 975 | - | if modalBackdrop { | |
| 976 | - | chat = tui.DimLines(chat) | |
| 977 | - | visibleChat = tui.DimLines(visibleChat) | |
| 978 | - | dimmedBottom := tui.DimLines(bottom) | |
| 979 | - | copy(dimmedBottom[1:1+len(dialog)], dialog) | |
| 980 | - | bottom = dimmedBottom | |
| 981 | - | } | |
| 982 | - | ||
| 983 | 983 | // Default: the real terminal cursor sits on the main editor's | |
| 984 | 984 | // input position. In main-screen log mode cursor rows are relative | |
| 985 | 985 | // to the fixed bottom band, not the chat transcript. | |
| 986 | - | // dialogLead is 1 when the bottom region prepends a blank above | |
| 987 | - | // the dialog block (whenever a dialog is showing) so popup-side | |
| 988 | - | // cursor positions still land in the right cell. | |
| 986 | + | // Floating content has its own coordinate space, so dialog cursor rows | |
| 987 | + | // remain relative to the content passed to FloatingPane.Compose. | |
| 989 | 988 | dialogLead := 0 | |
| 990 | - | if len(dialog) > 0 { | |
| 991 | - | dialogLead = 1 | |
| 992 | - | } | |
| 993 | 989 | cursorRow := inputStartRow + inputCursorOffset + curR | |
| 994 | 990 | cursorCol := curC + inputCursorColOffset | |
| 995 | 991 | cursorInDialog := false | |
| 996 | 992 | if i.btwDialog.Active() { | |
| 997 | - | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | |
| 993 | + | if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 { | |
| 998 | 994 | cursorRow = dialogLead + r | |
| 999 | 995 | cursorCol = c | |
| 1000 | 996 | cursorInDialog = true | |
| 1001 | 997 | } | |
| 1002 | 998 | } | |
| 1003 | 999 | if i.dialog.Active() { | |
| 1004 | - | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | |
| 1000 | + | if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 { | |
| 1005 | 1001 | cursorRow = dialogLead + r | |
| 1006 | 1002 | cursorCol = c | |
| 1007 | 1003 | cursorInDialog = true | |
| @@ -1035,6 +1031,13 @@ func (i *Interactive) redraw() { | |||
| 1035 | 1031 | cursorRow = -1 | |
| 1036 | 1032 | cursorCol = 0 | |
| 1037 | 1033 | } | |
| 1034 | + | dialogCursorRow, dialogCursorCol := dialogFocusRow, 0 | |
| 1035 | + | if cursorInDialog { | |
| 1036 | + | dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol | |
| 1037 | + | if dialogCursorRow < 0 { | |
| 1038 | + | dialogCursorRow = -1 | |
| 1039 | + | } | |
| 1040 | + | } | |
| 1038 | 1041 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |
| 1039 | 1042 | if i.renderRevision.Load() != renderRevision { | |
| 1040 | 1043 | i.mu.Unlock() | |
| @@ -1044,13 +1047,17 @@ func (i *Interactive) redraw() { | |||
| 1044 | 1047 | // State assembly above is synchronized; terminal output is owned by | |
| 1045 | 1048 | // this renderer goroutine and must not hold the global state mutex. | |
| 1046 | 1049 | i.mu.Unlock() | |
| 1047 | - | if rightBarActive { | |
| 1048 | - | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 1049 | - | if modalBackdrop { | |
| 1050 | - | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | |
| 1051 | - | } else { | |
| 1052 | - | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 1050 | + | if modalBackdrop { | |
| 1051 | + | background := floatingBackgroundFrame(visibleChat, bottom, rows) | |
| 1052 | + | if rightBarActive { | |
| 1053 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 1054 | + | background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth) | |
| 1053 | 1055 | } | |
| 1056 | + | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | |
| 1057 | + | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | |
| 1058 | + | } else if rightBarActive { | |
| 1059 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 1060 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 1054 | 1061 | } else { | |
| 1055 | 1062 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 1056 | 1063 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| @@ -1063,6 +1070,69 @@ func (i *Interactive) redraw() { | |||
| 1063 | 1070 | } | |
| 1064 | 1071 | i.mu.Unlock() | |
| 1065 | 1072 | } | |
| 1073 | + | ||
| 1074 | + | // floatingOverlayTitle supplies every overlay with a stable, human-readable | |
| 1075 | + | // title even when its body does not use the legacy frame-header convention. | |
| 1076 | + | func floatingOverlayTitle(id string) string { | |
| 1077 | + | return strings.ReplaceAll(id, "-", " ") | |
| 1078 | + | } | |
| 1079 | + | ||
| 1080 | + | // floatingDialogBody removes a legacy dialog's rule chrome and returns its | |
| 1081 | + | // title for the shared floating-pane border. | |
| 1082 | + | func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) { | |
| 1083 | + | if len(lines) == 0 { | |
| 1084 | + | return "", nil, 0 | |
| 1085 | + | } | |
| 1086 | + | body = lines | |
| 1087 | + | if isFrameHeaderLine(body[0]) { | |
| 1088 | + | plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ") | |
| 1089 | + | title = strings.TrimSpace(strings.TrimRight(plain, "─")) | |
| 1090 | + | body = body[1:] | |
| 1091 | + | removedTopRows = 1 | |
| 1092 | + | } | |
| 1093 | + | if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) { | |
| 1094 | + | body = body[:last] | |
| 1095 | + | } | |
| 1096 | + | return title, body, removedTopRows | |
| 1097 | + | } | |
| 1098 | + | ||
| 1099 | + | // floatingBackgroundFrame preserves the regular viewport layout while an | |
| 1100 | + | // independent view is open. It is a fixed terminal frame only for the overlay | |
| 1101 | + | // lifetime; normal rendering immediately returns to scrollback-oriented flow | |
| 1102 | + | // when the pane closes. | |
| 1103 | + | func floatingBackgroundFrame(chat, bottom []string, rows int) []string { | |
| 1104 | + | if rows < 1 { | |
| 1105 | + | return nil | |
| 1106 | + | } | |
| 1107 | + | // Match DrawLog's logical order and renderer-owned bottom margin, then | |
| 1108 | + | // retain exactly the terminal viewport. In particular, an editor/status | |
| 1109 | + | // band taller than the terminal must show its newest rows, not its oldest. | |
| 1110 | + | lines := make([]string, 0, len(chat)+len(bottom)+1) | |
| 1111 | + | lines = append(lines, chat...) | |
| 1112 | + | lines = append(lines, bottom...) | |
| 1113 | + | lines = append(lines, "") | |
| 1114 | + | if len(lines) > rows { | |
| 1115 | + | lines = lines[len(lines)-rows:] | |
| 1116 | + | } | |
| 1117 | + | frame := make([]string, rows) | |
| 1118 | + | // DrawLog's flow renderer leaves the logical session content at the | |
| 1119 | + | // viewport's top. Keep that anchoring when switching to a fixed floating | |
| 1120 | + | // frame so opening a pane does not visibly jump the whole session down. | |
| 1121 | + | copy(frame, lines) | |
| 1122 | + | return frame | |
| 1123 | + | } | |
| 1124 | + | ||
| 1125 | + | func floatingRightBarFrame(theme tui.Theme, main, rightBar []string, mainWidth, rightBarWidth int) []string { | |
| 1126 | + | for row := range main { | |
| 1127 | + | barLine := "" | |
| 1128 | + | if row < len(rightBar) { | |
| 1129 | + | barLine = rightBar[row] | |
| 1130 | + | } | |
| 1131 | + | main[row] = tui.JoinRightBar(theme, main[row], barLine, mainWidth, rightBarWidth) | |
| 1132 | + | } | |
| 1133 | + | return main | |
| 1134 | + | } | |
| 1135 | + | ||
| 1066 | 1136 | func hasImageEscape(line string) bool { | |
| 1067 | 1137 | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | |
| 1068 | 1138 | } | |
| @@ -492,75 +492,94 @@ func (i *Interactive) redraw() { | |||||
| 492 | return | 492 | return | ||
| 493 | } | 493 | } | ||
| 494 | 494 | ||||
| 495 | - | // Dialogs (login or model picker) render between chat and the editor. | 495 | + | // Independent views render into a floating pane over the live transcript. |
| 496 | + | // The pane owns its geometry and viewport; each view retains its own local | |||
| 497 | + | // selection, editor, and asynchronous state. | |||
| 498 | + | paneMax := tui.FloatingPaneMaxRect(cols, rows) | |||
| 499 | + | dialogWidth := paneMax.ContentWidth() | |||
| 496 | var dialog []string | 500 | var dialog []string | ||
| 501 | + | var dialogID string | |||
| 497 | switch { | 502 | switch { | ||
| 498 | case i.dialog.Active(): | 503 | case i.dialog.Active(): | ||
| 499 | - | dialog = i.dialog.Render(i.cfg.Theme, mainCols) | 504 | + | dialogID = "login" |
| 505 | + | dialog = i.dialog.Render(i.cfg.Theme, dialogWidth) | |||
| 500 | case i.modelDialog.Active(): | 506 | case i.modelDialog.Active(): | ||
| 501 | - | dialog = i.modelDialog.Render(i.cfg.Theme, mainCols) | 507 | + | dialogID = "model" |
| 508 | + | dialog = i.modelDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 502 | case i.llamaDialog.Active(): | 509 | case i.llamaDialog.Active(): | ||
| 503 | - | dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols) | 510 | + | dialogID = "llama" |
| 511 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 504 | case i.rescueDialog.Active(): | 512 | case i.rescueDialog.Active(): | ||
| 505 | - | dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols) | 513 | + | dialogID = "rescue" |
| 514 | + | dialog = i.rescueDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 506 | case i.sessionDialog.Active(): | 515 | case i.sessionDialog.Active(): | ||
| 507 | - | // Reserve rows for the editor (~3), status line (1-2), | 516 | + | dialogID = "sessions" |
| 508 | - | // dialog chrome (header + hint + rule + indicators, ~5), | 517 | + | i.sessionDialog.MaxRows = max(3, paneMax.ContentHeight()-5) |
| 509 | - | // and leave the remainder for session rows. Minimum of 3 | 518 | + | dialog = i.sessionDialog.Render(i.cfg.Theme, dialogWidth) |
| 510 | - | // rows so even a very small terminal shows something. | |||
| 511 | - | _, rows := i.cfg.Terminal.Size() | |||
| 512 | - | avail := rows - 12 | |||
| 513 | - | if avail < 3 { | |||
| 514 | - | avail = 3 | |||
| 515 | - | } | |||
| 516 | - | i.sessionDialog.MaxRows = avail | |||
| 517 | - | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | |||
| 518 | case i.residentSubagentsDialog.Active(): | 519 | case i.residentSubagentsDialog.Active(): | ||
| 519 | - | _, terminalRows := i.cfg.Terminal.Size() | 520 | + | dialogID = "subagents" |
| 520 | - | dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows) | 521 | + | dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, dialogWidth, paneMax.ContentHeight()) |
| 521 | case i.residentChildSession != nil: | 522 | case i.residentChildSession != nil: | ||
| 522 | - | _, terminalRows := i.cfg.Terminal.Size() | 523 | + | dialogID = "subagent-session" |
| 523 | - | dialog = i.residentChildSession.Render(mainCols, terminalRows) | 524 | + | dialog = i.residentChildSession.Render(dialogWidth, paneMax.ContentHeight()) |
| 524 | case i.jumpDialog.Active(): | 525 | case i.jumpDialog.Active(): | ||
| 525 | - | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | 526 | + | dialogID = "jump" |
| 527 | + | dialog = i.jumpDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 526 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | 528 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | ||
| 527 | - | dialog = i.extPanel.Render(i.cfg.Theme, mainCols) | 529 | + | dialogID = "extension-panel" |
| 530 | + | dialog = i.extPanel.Render(i.cfg.Theme, dialogWidth) | |||
| 528 | case i.confirmDialog.Active(): | 531 | case i.confirmDialog.Active(): | ||
| 532 | + | dialogID = "confirmation" | |||
| 529 | if i.btwDialog.Active() { | 533 | if i.btwDialog.Active() { | ||
| 530 | - | // Keep the side-chat transcript and its live tool preview visible | 534 | + | dialog = renderBtwConfirmation(i.cfg.Theme, dialogWidth, i.btwDialog, i.confirmDialog) |
| 531 | - | // above confirmation, matching how the main transcript remains | |||
| 532 | - | // visible while --no-yolo waits for a decision. | |||
| 533 | - | dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog) | |||
| 534 | } else { | 535 | } else { | ||
| 535 | - | dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols) | 536 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, dialogWidth) |
| 536 | } | 537 | } | ||
| 537 | case i.btwDialog.Active(): | 538 | case i.btwDialog.Active(): | ||
| 538 | - | dialog = i.btwDialog.Render(i.cfg.Theme, mainCols) | 539 | + | dialogID = "side-chat" |
| 540 | + | dialog = i.btwDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 539 | case i.skillsDialog.Active(): | 541 | case i.skillsDialog.Active(): | ||
| 540 | - | dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols) | 542 | + | dialogID = "skills" |
| 543 | + | if i.skillsDialog.Viewing() { | |||
| 544 | + | dialogID = "skills-body" | |||
| 545 | + | } | |||
| 546 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 541 | case i.changelogDialog.Active(): | 547 | case i.changelogDialog.Active(): | ||
| 542 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) | 548 | + | dialogID = "changelog" |
| 549 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 543 | case i.logoutDialog.Active(): | 550 | case i.logoutDialog.Active(): | ||
| 544 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) | 551 | + | dialogID = "logout" |
| 552 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 545 | case i.telegramDialog.Active(): | 553 | case i.telegramDialog.Active(): | ||
| 546 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) | 554 | + | dialogID = "telegram" |
| 555 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 547 | case i.settingsDialog.Active(): | 556 | case i.settingsDialog.Active(): | ||
| 548 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) | 557 | + | dialogID = "settings" |
| 558 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 549 | case i.sessionOpsDialog.Active(): | 559 | case i.sessionOpsDialog.Active(): | ||
| 550 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) | 560 | + | dialogID = "session-ops" |
| 561 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 551 | case i.sessionTreeDialog.Active(): | 562 | case i.sessionTreeDialog.Active(): | ||
| 552 | - | // Reserve rows for the editor, status, and tree chrome while using | 563 | + | dialogID = "session-tree" |
| 553 | - | // the same budget for rendering and PageUp/PageDown movement. | 564 | + | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) |
| 554 | - | _, rows := i.cfg.Terminal.Size() | 565 | + | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) |
| 555 | - | avail := rows - 12 | |||
| 556 | - | if avail < 3 { | |||
| 557 | - | avail = 3 | |||
| 558 | - | } | |||
| 559 | - | i.sessionTreeDialog.MaxRows = avail | |||
| 560 | - | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols) | |||
| 561 | } | 566 | } | ||
| 567 | + | dialogTitle := floatingOverlayTitle(dialogID) | |||
| 568 | + | dialogRemovedTopRows := 0 | |||
| 562 | if len(dialog) > 0 { | 569 | if len(dialog) > 0 { | ||
| 563 | dialog = padDialogFrame(dialog) | 570 | dialog = padDialogFrame(dialog) | ||
| 571 | + | frameTitle, body, removedTopRows := floatingDialogBody(dialog) | |||
| 572 | + | if frameTitle != "" { | |||
| 573 | + | dialogTitle = frameTitle | |||
| 574 | + | } | |||
| 575 | + | dialog, dialogRemovedTopRows = body, removedTopRows | |||
| 576 | + | } | |||
| 577 | + | dialogFocusRow := -1 | |||
| 578 | + | for row, line := range dialog { | |||
| 579 | + | if strings.Contains(line, i.cfg.Theme.SelectionStyle()) { | |||
| 580 | + | dialogFocusRow = row | |||
| 581 | + | break | |||
| 582 | + | } | |||
| 564 | } | 583 | } | ||
| 565 | modalBackdrop := len(dialog) > 0 | 584 | modalBackdrop := len(dialog) > 0 | ||
| 566 | 585 | ||||
| @@ -695,10 +714,12 @@ func (i *Interactive) redraw() { | |||||
| 695 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | 714 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | ||
| 696 | } | 715 | } | ||
| 697 | edLines, curR, curC := i.ed.Render(mainCols) | 716 | edLines, curR, curC := i.ed.Render(mainCols) | ||
| 698 | - | dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | 717 | + | // Floating independent views no longer replace the main bottom band: the |
| 718 | + | // underlying chat, status, and editor keep updating behind the pane. | |||
| 719 | + | residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |||
| 699 | var allResidentSubagentLines []string | 720 | var allResidentSubagentLines []string | ||
| 700 | residentSubagentHidden := 0 | 721 | residentSubagentHidden := 0 | ||
| 701 | - | if !dashboardActive && i.cfg.ResidentManager != nil { | 722 | + | if !residentViewActive && i.cfg.ResidentManager != nil { |
| 702 | const residentIndicatorSnapshotLimit = 8 | 723 | const residentIndicatorSnapshotLimit = 8 | ||
| 703 | snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit) | 724 | snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit) | ||
| 704 | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | 725 | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | ||
| @@ -752,17 +773,7 @@ func (i *Interactive) redraw() { | |||||
| 752 | // blanks so spacing stays consistent whether or not a dialog or | 773 | // blanks so spacing stays consistent whether or not a dialog or | ||
| 753 | // popup is showing. | 774 | // popup is showing. | ||
| 754 | composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) { | 775 | composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) { | ||
| 755 | - | bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) | 776 | + | bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) |
| 756 | - | inputStartRow = -1 | |||
| 757 | - | if len(dialog) > 0 { | |||
| 758 | - | bottom = append(bottom, "") | |||
| 759 | - | } | |||
| 760 | - | bottom = append(bottom, dialog...) | |||
| 761 | - | // The resident child views own the bottom of the screen while active. | |||
| 762 | - | if dashboardActive { | |||
| 763 | - | return bottom, inputStartRow | |||
| 764 | - | } | |||
| 765 | - | ||||
| 766 | bottom = append(bottom, suggest...) | 777 | bottom = append(bottom, suggest...) | ||
| 767 | bottom = append(bottom, queue...) | 778 | bottom = append(bottom, queue...) | ||
| 768 | lineInput := inputStyle == tui.InputStyleLines | 779 | lineInput := inputStyle == tui.InputStyleLines | ||
| @@ -814,7 +825,7 @@ func (i *Interactive) redraw() { | |||||
| 814 | var residentSubagentLines []string | 825 | var residentSubagentLines []string | ||
| 815 | var bottom []string | 826 | var bottom []string | ||
| 816 | var inputStartRow int | 827 | var inputStartRow int | ||
| 817 | - | if !dashboardActive && len(allResidentSubagentLines) > 0 { | 828 | + | if len(allResidentSubagentLines) > 0 { |
| 818 | for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- { | 829 | for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- { | ||
| 819 | candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols) | 830 | candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols) | ||
| 820 | candidateBottom, _ := composeBottom(candidate) | 831 | candidateBottom, _ := composeBottom(candidate) | ||
| @@ -969,39 +980,24 @@ func (i *Interactive) redraw() { | |||||
| 969 | } | 980 | } | ||
| 970 | } | 981 | } | ||
| 971 | 982 | ||||
| 972 | - | // A dialog is the foreground layer. Dim every other visible surface, | |||
| 973 | - | // including the main input beneath it, without changing the dialog's | |||
| 974 | - | // own styling. Keep the source slices untouched because chat is cached. | |||
| 975 | - | if modalBackdrop { | |||
| 976 | - | chat = tui.DimLines(chat) | |||
| 977 | - | visibleChat = tui.DimLines(visibleChat) | |||
| 978 | - | dimmedBottom := tui.DimLines(bottom) | |||
| 979 | - | copy(dimmedBottom[1:1+len(dialog)], dialog) | |||
| 980 | - | bottom = dimmedBottom | |||
| 981 | - | } | |||
| 982 | - | ||||
| 983 | // Default: the real terminal cursor sits on the main editor's | 983 | // Default: the real terminal cursor sits on the main editor's | ||
| 984 | // input position. In main-screen log mode cursor rows are relative | 984 | // input position. In main-screen log mode cursor rows are relative | ||
| 985 | // to the fixed bottom band, not the chat transcript. | 985 | // to the fixed bottom band, not the chat transcript. | ||
| 986 | - | // dialogLead is 1 when the bottom region prepends a blank above | 986 | + | // Floating content has its own coordinate space, so dialog cursor rows |
| 987 | - | // the dialog block (whenever a dialog is showing) so popup-side | 987 | + | // remain relative to the content passed to FloatingPane.Compose. |
| 988 | - | // cursor positions still land in the right cell. | |||
| 989 | dialogLead := 0 | 988 | dialogLead := 0 | ||
| 990 | - | if len(dialog) > 0 { | |||
| 991 | - | dialogLead = 1 | |||
| 992 | - | } | |||
| 993 | cursorRow := inputStartRow + inputCursorOffset + curR | 989 | cursorRow := inputStartRow + inputCursorOffset + curR | ||
| 994 | cursorCol := curC + inputCursorColOffset | 990 | cursorCol := curC + inputCursorColOffset | ||
| 995 | cursorInDialog := false | 991 | cursorInDialog := false | ||
| 996 | if i.btwDialog.Active() { | 992 | if i.btwDialog.Active() { | ||
| 997 | - | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | 993 | + | if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 { |
| 998 | cursorRow = dialogLead + r | 994 | cursorRow = dialogLead + r | ||
| 999 | cursorCol = c | 995 | cursorCol = c | ||
| 1000 | cursorInDialog = true | 996 | cursorInDialog = true | ||
| 1001 | } | 997 | } | ||
| 1002 | } | 998 | } | ||
| 1003 | if i.dialog.Active() { | 999 | if i.dialog.Active() { | ||
| 1004 | - | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | 1000 | + | if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 { |
| 1005 | cursorRow = dialogLead + r | 1001 | cursorRow = dialogLead + r | ||
| 1006 | cursorCol = c | 1002 | cursorCol = c | ||
| 1007 | cursorInDialog = true | 1003 | cursorInDialog = true | ||
| @@ -1035,6 +1031,13 @@ func (i *Interactive) redraw() { | |||||
| 1035 | cursorRow = -1 | 1031 | cursorRow = -1 | ||
| 1036 | cursorCol = 0 | 1032 | cursorCol = 0 | ||
| 1037 | } | 1033 | } | ||
| 1034 | + | dialogCursorRow, dialogCursorCol := dialogFocusRow, 0 | |||
| 1035 | + | if cursorInDialog { | |||
| 1036 | + | dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol | |||
| 1037 | + | if dialogCursorRow < 0 { | |||
| 1038 | + | dialogCursorRow = -1 | |||
| 1039 | + | } | |||
| 1040 | + | } | |||
| 1038 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | 1041 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | ||
| 1039 | if i.renderRevision.Load() != renderRevision { | 1042 | if i.renderRevision.Load() != renderRevision { | ||
| 1040 | i.mu.Unlock() | 1043 | i.mu.Unlock() | ||
| @@ -1044,13 +1047,17 @@ func (i *Interactive) redraw() { | |||||
| 1044 | // State assembly above is synchronized; terminal output is owned by | 1047 | // State assembly above is synchronized; terminal output is owned by | ||
| 1045 | // this renderer goroutine and must not hold the global state mutex. | 1048 | // this renderer goroutine and must not hold the global state mutex. | ||
| 1046 | i.mu.Unlock() | 1049 | i.mu.Unlock() | ||
| 1047 | - | if rightBarActive { | 1050 | + | if modalBackdrop { |
| 1048 | - | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | 1051 | + | background := floatingBackgroundFrame(visibleChat, bottom, rows) |
| 1049 | - | if modalBackdrop { | 1052 | + | if rightBarActive { |
| 1050 | - | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | 1053 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) |
| 1051 | - | } else { | 1054 | + | background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth) |
| 1052 | - | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |||
| 1053 | } | 1055 | } | ||
| 1056 | + | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | |||
| 1057 | + | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | |||
| 1058 | + | } else if rightBarActive { | |||
| 1059 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |||
| 1060 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |||
| 1054 | } else { | 1061 | } else { | ||
| 1055 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 1062 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | ||
| 1056 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 1063 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | ||
| @@ -1063,6 +1070,69 @@ func (i *Interactive) redraw() { | |||||
| 1063 | } | 1070 | } | ||
| 1064 | i.mu.Unlock() | 1071 | i.mu.Unlock() | ||
| 1065 | } | 1072 | } | ||
| 1073 | + | ||||
| 1074 | + | // floatingOverlayTitle supplies every overlay with a stable, human-readable | |||
| 1075 | + | // title even when its body does not use the legacy frame-header convention. | |||
| 1076 | + | func floatingOverlayTitle(id string) string { | |||
| 1077 | + | return strings.ReplaceAll(id, "-", " ") | |||
| 1078 | + | } | |||
| 1079 | + | ||||
| 1080 | + | // floatingDialogBody removes a legacy dialog's rule chrome and returns its | |||
| 1081 | + | // title for the shared floating-pane border. | |||
| 1082 | + | func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) { | |||
| 1083 | + | if len(lines) == 0 { | |||
| 1084 | + | return "", nil, 0 | |||
| 1085 | + | } | |||
| 1086 | + | body = lines | |||
| 1087 | + | if isFrameHeaderLine(body[0]) { | |||
| 1088 | + | plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ") | |||
| 1089 | + | title = strings.TrimSpace(strings.TrimRight(plain, "─")) | |||
| 1090 | + | body = body[1:] | |||
| 1091 | + | removedTopRows = 1 | |||
| 1092 | + | } | |||
| 1093 | + | if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) { | |||
| 1094 | + | body = body[:last] | |||
| 1095 | + | } | |||
| 1096 | + | return title, body, removedTopRows | |||
| 1097 | + | } | |||
| 1098 | + | ||||
| 1099 | + | // floatingBackgroundFrame preserves the regular viewport layout while an | |||
| 1100 | + | // independent view is open. It is a fixed terminal frame only for the overlay | |||
| 1101 | + | // lifetime; normal rendering immediately returns to scrollback-oriented flow | |||
| 1102 | + | // when the pane closes. | |||
| 1103 | + | func floatingBackgroundFrame(chat, bottom []string, rows int) []string { | |||
| 1104 | + | if rows < 1 { | |||
| 1105 | + | return nil | |||
| 1106 | + | } | |||
| 1107 | + | // Match DrawLog's logical order and renderer-owned bottom margin, then | |||
| 1108 | + | // retain exactly the terminal viewport. In particular, an editor/status | |||
| 1109 | + | // band taller than the terminal must show its newest rows, not its oldest. | |||
| 1110 | + | lines := make([]string, 0, len(chat)+len(bottom)+1) | |||
| 1111 | + | lines = append(lines, chat...) | |||
| 1112 | + | lines = append(lines, bottom...) | |||
| 1113 | + | lines = append(lines, "") | |||
| 1114 | + | if len(lines) > rows { | |||
| 1115 | + | lines = lines[len(lines)-rows:] | |||
| 1116 | + | } | |||
| 1117 | + | frame := make([]string, rows) | |||
| 1118 | + | // DrawLog's flow renderer leaves the logical session content at the | |||
| 1119 | + | // viewport's top. Keep that anchoring when switching to a fixed floating | |||
| 1120 | + | // frame so opening a pane does not visibly jump the whole session down. | |||
| 1121 | + | copy(frame, lines) | |||
| 1122 | + | return frame | |||
| 1123 | + | } | |||
| 1124 | + | ||||
| 1125 | + | func floatingRightBarFrame(theme tui.Theme, main, rightBar []string, mainWidth, rightBarWidth int) []string { | |||
| 1126 | + | for row := range main { | |||
| 1127 | + | barLine := "" | |||
| 1128 | + | if row < len(rightBar) { | |||
| 1129 | + | barLine = rightBar[row] | |||
| 1130 | + | } | |||
| 1131 | + | main[row] = tui.JoinRightBar(theme, main[row], barLine, mainWidth, rightBarWidth) | |||
| 1132 | + | } | |||
| 1133 | + | return main | |||
| 1134 | + | } | |||
| 1135 | + | ||||
| 1066 | func hasImageEscape(line string) bool { | 1136 | func hasImageEscape(line string) bool { | ||
| 1067 | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | 1137 | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | ||
| 1068 | } | 1138 | } | ||
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/login_dialog_test.go +5 -2
| @@ -76,8 +76,11 @@ func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | |||
| 76 | 76 | d.provider = "anthropic" | |
| 77 | 77 | d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz") | |
| 78 | 78 | ||
| 79 | - | lines := padDialogFrame(d.Render(tui.Theme{}, 80)) | |
| 80 | - | row, _ := d.CursorPos(80) | |
| 79 | + | // Floating panes render this dialog narrower than the main terminal, so | |
| 80 | + | // CursorPos must use the identical width to retain wrapping alignment. | |
| 81 | + | const paneWidth = 62 | |
| 82 | + | lines := padDialogFrame(d.Render(tui.Theme{}, paneWidth)) | |
| 83 | + | row, _ := d.CursorPos(paneWidth) | |
| 81 | 84 | if row < 0 || row >= len(lines) { | |
| 82 | 85 | t.Fatalf("CursorPos row = %d outside rendered lines %d", row, len(lines)) | |
| 83 | 86 | } | |
| @@ -76,8 +76,11 @@ func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | |||||
| 76 | d.provider = "anthropic" | 76 | d.provider = "anthropic" | ||
| 77 | d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz") | 77 | d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz") | ||
| 78 | 78 | ||||
| 79 | - | lines := padDialogFrame(d.Render(tui.Theme{}, 80)) | 79 | + | // Floating panes render this dialog narrower than the main terminal, so |
| 80 | - | row, _ := d.CursorPos(80) | 80 | + | // CursorPos must use the identical width to retain wrapping alignment. |
| 81 | + | const paneWidth = 62 | |||
| 82 | + | lines := padDialogFrame(d.Render(tui.Theme{}, paneWidth)) | |||
| 83 | + | row, _ := d.CursorPos(paneWidth) | |||
| 81 | if row < 0 || row >= len(lines) { | 84 | if row < 0 || row >= len(lines) { | ||
| 82 | t.Fatalf("CursorPos row = %d outside rendered lines %d", row, len(lines)) | 85 | t.Fatalf("CursorPos row = %d outside rendered lines %d", row, len(lines)) | ||
| 83 | } | 86 | } | ||
M packages/agent/modes/modal_backdrop_test.go +64 -2
| @@ -7,6 +7,56 @@ 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 | + | ||
| 16 | + | func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) { | |
| 17 | + | lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)}) | |
| 18 | + | title, body, removed := floatingDialogBody(lines) | |
| 19 | + | if title != "settings" || removed != 1 { | |
| 20 | + | t.Fatalf("chrome extraction = title:%q removed:%d, want settings/1", title, removed) | |
| 21 | + | } | |
| 22 | + | if strings.Join(body, "\n") != "\nbody\n" { | |
| 23 | + | t.Fatalf("dialog body retained frame chrome: %q", body) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestFloatingBackgroundFramePreservesTopAnchoredViewport(t *testing.T) { | |
| 28 | + | tests := []struct { | |
| 29 | + | name string | |
| 30 | + | chat []string | |
| 31 | + | bottom []string | |
| 32 | + | rows int | |
| 33 | + | want []string | |
| 34 | + | }{ | |
| 35 | + | { | |
| 36 | + | name: "session stays at top", | |
| 37 | + | chat: []string{"chat"}, | |
| 38 | + | bottom: []string{"status"}, | |
| 39 | + | rows: 5, | |
| 40 | + | want: []string{"chat", "status", "", "", ""}, | |
| 41 | + | }, | |
| 42 | + | { | |
| 43 | + | name: "oversized bottom retains newest rows", | |
| 44 | + | chat: []string{"chat"}, | |
| 45 | + | bottom: []string{"old", "newer", "newest"}, | |
| 46 | + | rows: 3, | |
| 47 | + | want: []string{"newer", "newest", ""}, | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | for _, tt := range tests { | |
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |
| 52 | + | got := floatingBackgroundFrame(tt.chat, tt.bottom, tt.rows) | |
| 53 | + | if strings.Join(got, "\n") != strings.Join(tt.want, "\n") { | |
| 54 | + | t.Fatalf("floating background = %q, want %q", got, tt.want) | |
| 55 | + | } | |
| 56 | + | }) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 10 | 60 | func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |
| 11 | 61 | term := &alertTestTerminal{} | |
| 12 | 62 | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | |
| @@ -23,8 +73,20 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |||
| 23 | 73 | if !strings.Contains(got, tui.Dim(input)) { | |
| 24 | 74 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | |
| 25 | 75 | } | |
| 26 | - | if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) { | |
| 27 | - | t.Fatalf("settings dialog itself was dimmed: %q", got) | |
| 76 | + | pane := tui.FloatingPaneMaxRect(80, 24) | |
| 77 | + | innerHeader := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth()) | |
| 78 | + | if strings.Contains(got, innerHeader) { | |
| 79 | + | t.Fatalf("settings retained its inner frame header: %q", got) | |
| 80 | + | } | |
| 81 | + | if !strings.Contains(got, " settings ") { | |
| 82 | + | t.Fatalf("settings title was not rendered in the floating border: %q", got) | |
| 83 | + | } | |
| 84 | + | if strings.Contains(got, tui.Dim(" settings ")) { | |
| 85 | + | t.Fatalf("floating pane title was dimmed: %q", got) | |
| 86 | + | } | |
| 87 | + | // The pane shrinks to its chrome-free content and remains centered. | |
| 88 | + | if !strings.Contains(got, tui.MoveTo(10, pane.X+1)) { | |
| 89 | + | t.Fatalf("floating pane was not positioned at its computed geometry: %q", got) | |
| 28 | 90 | } | |
| 29 | 91 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | |
| 30 | 92 | if !strings.Contains(got, cursor) { | |
| @@ -7,6 +7,56 @@ 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 | + | ||||
| 16 | + | func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) { | |||
| 17 | + | lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)}) | |||
| 18 | + | title, body, removed := floatingDialogBody(lines) | |||
| 19 | + | if title != "settings" || removed != 1 { | |||
| 20 | + | t.Fatalf("chrome extraction = title:%q removed:%d, want settings/1", title, removed) | |||
| 21 | + | } | |||
| 22 | + | if strings.Join(body, "\n") != "\nbody\n" { | |||
| 23 | + | t.Fatalf("dialog body retained frame chrome: %q", body) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestFloatingBackgroundFramePreservesTopAnchoredViewport(t *testing.T) { | |||
| 28 | + | tests := []struct { | |||
| 29 | + | name string | |||
| 30 | + | chat []string | |||
| 31 | + | bottom []string | |||
| 32 | + | rows int | |||
| 33 | + | want []string | |||
| 34 | + | }{ | |||
| 35 | + | { | |||
| 36 | + | name: "session stays at top", | |||
| 37 | + | chat: []string{"chat"}, | |||
| 38 | + | bottom: []string{"status"}, | |||
| 39 | + | rows: 5, | |||
| 40 | + | want: []string{"chat", "status", "", "", ""}, | |||
| 41 | + | }, | |||
| 42 | + | { | |||
| 43 | + | name: "oversized bottom retains newest rows", | |||
| 44 | + | chat: []string{"chat"}, | |||
| 45 | + | bottom: []string{"old", "newer", "newest"}, | |||
| 46 | + | rows: 3, | |||
| 47 | + | want: []string{"newer", "newest", ""}, | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | for _, tt := range tests { | |||
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 52 | + | got := floatingBackgroundFrame(tt.chat, tt.bottom, tt.rows) | |||
| 53 | + | if strings.Join(got, "\n") != strings.Join(tt.want, "\n") { | |||
| 54 | + | t.Fatalf("floating background = %q, want %q", got, tt.want) | |||
| 55 | + | } | |||
| 56 | + | }) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 10 | func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | 60 | func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | ||
| 11 | term := &alertTestTerminal{} | 61 | term := &alertTestTerminal{} | ||
| 12 | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | 62 | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | ||
| @@ -23,8 +73,20 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |||||
| 23 | if !strings.Contains(got, tui.Dim(input)) { | 73 | if !strings.Contains(got, tui.Dim(input)) { | ||
| 24 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | 74 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | ||
| 25 | } | 75 | } | ||
| 26 | - | if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) { | 76 | + | pane := tui.FloatingPaneMaxRect(80, 24) |
| 27 | - | t.Fatalf("settings dialog itself was dimmed: %q", got) | 77 | + | innerHeader := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth()) |
| 78 | + | if strings.Contains(got, innerHeader) { | |||
| 79 | + | t.Fatalf("settings retained its inner frame header: %q", got) | |||
| 80 | + | } | |||
| 81 | + | if !strings.Contains(got, " settings ") { | |||
| 82 | + | t.Fatalf("settings title was not rendered in the floating border: %q", got) | |||
| 83 | + | } | |||
| 84 | + | if strings.Contains(got, tui.Dim(" settings ")) { | |||
| 85 | + | t.Fatalf("floating pane title was dimmed: %q", got) | |||
| 86 | + | } | |||
| 87 | + | // The pane shrinks to its chrome-free content and remains centered. | |||
| 88 | + | if !strings.Contains(got, tui.MoveTo(10, pane.X+1)) { | |||
| 89 | + | t.Fatalf("floating pane was not positioned at its computed geometry: %q", got) | |||
| 28 | } | 90 | } | ||
| 29 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | 91 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | ||
| 30 | if !strings.Contains(got, cursor) { | 92 | if !strings.Contains(got, cursor) { | ||
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 +113 -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 | |
| @@ -200,6 +297,15 @@ func (s *residentChildSession) HandleKey(k tui.Key) (string, bool) { | |||
| 200 | 297 | return prompt, prompt != "" | |
| 201 | 298 | } | |
| 202 | 299 | ||
| 300 | + | func (s *residentChildSession) HandleNavigation(k tui.Key) bool { | |
| 301 | + | if s == nil || s.composer == nil { | |
| 302 | + | return false | |
| 303 | + | } | |
| 304 | + | s.mu.Lock() | |
| 305 | + | defer s.mu.Unlock() | |
| 306 | + | return s.composer.HandleKey(k) | |
| 307 | + | } | |
| 308 | + | ||
| 203 | 309 | func (s *residentChildSession) FinishSubmission(err error) { | |
| 204 | 310 | if s == nil { | |
| 205 | 311 | return | |
| @@ -245,10 +351,6 @@ func (s *residentChildSession) Render(width, height int) []string { | |||
| 245 | 351 | if s == nil { | |
| 246 | 352 | return nil | |
| 247 | 353 | } | |
| 248 | - | view := s.View() | |
| 249 | - | if view == nil { | |
| 250 | - | return []string{" resident subagent unavailable"} | |
| 251 | - | } | |
| 252 | 354 | s.mu.Lock() | |
| 253 | 355 | loading, loadErr := s.loading, s.err | |
| 254 | 356 | var editorLines []string | |
| @@ -275,7 +377,7 @@ func (s *residentChildSession) Render(width, height int) []string { | |||
| 275 | 377 | if loadErr != "" { | |
| 276 | 378 | lines = append(lines, " history: "+loadErr) | |
| 277 | 379 | } | |
| 278 | - | history := view.Build(width) | |
| 380 | + | history := s.renderedHistory(width) | |
| 279 | 381 | s.mu.Lock() | |
| 280 | 382 | if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 { | |
| 281 | 383 | s.scrollOffset += len(history) - s.lastRows | |
| @@ -305,7 +407,7 @@ func (s *residentChildSession) Render(width, height int) []string { | |||
| 305 | 407 | } | |
| 306 | 408 | lines = append(lines, "") | |
| 307 | 409 | lines = append(lines, editorLines...) | |
| 308 | - | lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp: load older history") | |
| 410 | + | lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp/PgDn: scroll history") | |
| 309 | 411 | if height <= 0 { | |
| 310 | 412 | return nil | |
| 311 | 413 | } | |
| @@ -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 | ||
| @@ -200,6 +297,15 @@ func (s *residentChildSession) HandleKey(k tui.Key) (string, bool) { | |||||
| 200 | return prompt, prompt != "" | 297 | return prompt, prompt != "" | ||
| 201 | } | 298 | } | ||
| 202 | 299 | ||||
| 300 | + | func (s *residentChildSession) HandleNavigation(k tui.Key) bool { | |||
| 301 | + | if s == nil || s.composer == nil { | |||
| 302 | + | return false | |||
| 303 | + | } | |||
| 304 | + | s.mu.Lock() | |||
| 305 | + | defer s.mu.Unlock() | |||
| 306 | + | return s.composer.HandleKey(k) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 203 | func (s *residentChildSession) FinishSubmission(err error) { | 309 | func (s *residentChildSession) FinishSubmission(err error) { | ||
| 204 | if s == nil { | 310 | if s == nil { | ||
| 205 | return | 311 | return | ||
| @@ -245,10 +351,6 @@ func (s *residentChildSession) Render(width, height int) []string { | |||||
| 245 | if s == nil { | 351 | if s == nil { | ||
| 246 | return nil | 352 | return nil | ||
| 247 | } | 353 | } | ||
| 248 | - | view := s.View() | |||
| 249 | - | if view == nil { | |||
| 250 | - | return []string{" resident subagent unavailable"} | |||
| 251 | - | } | |||
| 252 | s.mu.Lock() | 354 | s.mu.Lock() | ||
| 253 | loading, loadErr := s.loading, s.err | 355 | loading, loadErr := s.loading, s.err | ||
| 254 | var editorLines []string | 356 | var editorLines []string | ||
| @@ -275,7 +377,7 @@ func (s *residentChildSession) Render(width, height int) []string { | |||||
| 275 | if loadErr != "" { | 377 | if loadErr != "" { | ||
| 276 | lines = append(lines, " history: "+loadErr) | 378 | lines = append(lines, " history: "+loadErr) | ||
| 277 | } | 379 | } | ||
| 278 | - | history := view.Build(width) | 380 | + | history := s.renderedHistory(width) |
| 279 | s.mu.Lock() | 381 | s.mu.Lock() | ||
| 280 | if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 { | 382 | if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 { | ||
| 281 | s.scrollOffset += len(history) - s.lastRows | 383 | s.scrollOffset += len(history) - s.lastRows | ||
| @@ -305,7 +407,7 @@ func (s *residentChildSession) Render(width, height int) []string { | |||||
| 305 | } | 407 | } | ||
| 306 | lines = append(lines, "") | 408 | lines = append(lines, "") | ||
| 307 | lines = append(lines, editorLines...) | 409 | lines = append(lines, editorLines...) | ||
| 308 | - | lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp: load older history") | 410 | + | lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp/PgDn: scroll history") |
| 309 | if height <= 0 { | 411 | if height <= 0 { | ||
| 310 | return nil | 412 | return nil | ||
| 311 | } | 413 | } | ||
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 +6 -1
| @@ -98,15 +98,20 @@ func residentResultStatus(id string, result subagents.ResidentResult) string { | |||
| 98 | 98 | func (i *Interactive) openResidentChildSession(childID string) { | |
| 99 | 99 | session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme) | |
| 100 | 100 | i.mu.Lock() | |
| 101 | + | previous := i.residentChildSession | |
| 101 | 102 | if i.residentSubagentsDialog != nil { | |
| 102 | 103 | i.residentSubagentsDialog.Close() | |
| 103 | 104 | } | |
| 104 | 105 | i.residentChildSession = session | |
| 105 | 106 | i.mu.Unlock() | |
| 107 | + | if previous != nil { | |
| 108 | + | previous.Close() | |
| 109 | + | } | |
| 110 | + | i.setResidentChildMouseReporting(true) | |
| 106 | 111 | i.invalidate() | |
| 107 | 112 | if session.BeginLoad() { | |
| 108 | 113 | go func() { | |
| 109 | - | err := session.LoadRecent(200) | |
| 114 | + | err := session.LoadAll(200, i.invalidate) | |
| 110 | 115 | if session.FinishLoad(err) { | |
| 111 | 116 | i.reloadResidentChildSession(session) | |
| 112 | 117 | } | |
| @@ -98,15 +98,20 @@ func residentResultStatus(id string, result subagents.ResidentResult) string { | |||||
| 98 | func (i *Interactive) openResidentChildSession(childID string) { | 98 | func (i *Interactive) openResidentChildSession(childID string) { | ||
| 99 | session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme) | 99 | session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme) | ||
| 100 | i.mu.Lock() | 100 | i.mu.Lock() | ||
| 101 | + | previous := i.residentChildSession | |||
| 101 | if i.residentSubagentsDialog != nil { | 102 | if i.residentSubagentsDialog != nil { | ||
| 102 | i.residentSubagentsDialog.Close() | 103 | i.residentSubagentsDialog.Close() | ||
| 103 | } | 104 | } | ||
| 104 | i.residentChildSession = session | 105 | i.residentChildSession = session | ||
| 105 | i.mu.Unlock() | 106 | i.mu.Unlock() | ||
| 107 | + | if previous != nil { | |||
| 108 | + | previous.Close() | |||
| 109 | + | } | |||
| 110 | + | i.setResidentChildMouseReporting(true) | |||
| 106 | i.invalidate() | 111 | i.invalidate() | ||
| 107 | if session.BeginLoad() { | 112 | if session.BeginLoad() { | ||
| 108 | go func() { | 113 | go func() { | ||
| 109 | - | err := session.LoadRecent(200) | 114 | + | err := session.LoadAll(200, i.invalidate) |
| 110 | if session.FinishLoad(err) { | 115 | if session.FinishLoad(err) { | ||
| 111 | i.reloadResidentChildSession(session) | 116 | i.reloadResidentChildSession(session) | ||
| 112 | } | 117 | } | ||
A packages/tui/floating_pane.go +269 -0
| @@ -0,0 +1,269 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // FloatingPane is a stateful viewport for an independent TUI view. It keeps | |
| 6 | + | // its scroll position while the same view is active, follows a focused row | |
| 7 | + | // into view, and composes that view over a live background frame. | |
| 8 | + | // | |
| 9 | + | // A FloatingPane is deliberately frontend-neutral: callers own their view's | |
| 10 | + | // data and key handling, while the pane owns geometry, clipping, and visual | |
| 11 | + | // composition. | |
| 12 | + | type FloatingPane struct { | |
| 13 | + | id string | |
| 14 | + | scroll int | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // FloatingPaneRect describes a pane's outer border in zero-based terminal | |
| 18 | + | // coordinates. Content occupies the rectangle inside that border. | |
| 19 | + | type FloatingPaneRect struct { | |
| 20 | + | X, Y int | |
| 21 | + | Width, Height int | |
| 22 | + | Drawer bool | |
| 23 | + | Borderless bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (r FloatingPaneRect) ContentWidth() int { | |
| 27 | + | if r.Drawer || r.Borderless { | |
| 28 | + | return max(1, r.Width) | |
| 29 | + | } | |
| 30 | + | return max(1, r.Width-2) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (r FloatingPaneRect) ContentHeight() int { | |
| 34 | + | if r.Borderless { | |
| 35 | + | return max(1, r.Height) | |
| 36 | + | } | |
| 37 | + | if r.Drawer { | |
| 38 | + | return max(1, r.Height-1) | |
| 39 | + | } | |
| 40 | + | return max(1, r.Height-2) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (r FloatingPaneRect) contentX() int { | |
| 44 | + | if r.Drawer || r.Borderless { | |
| 45 | + | return r.X | |
| 46 | + | } | |
| 47 | + | return r.X + 1 | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (r FloatingPaneRect) contentY() int { | |
| 51 | + | if r.Borderless { | |
| 52 | + | return r.Y | |
| 53 | + | } | |
| 54 | + | return r.Y + 1 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // FloatingPaneMaxRect returns the largest pane for the current terminal. The | |
| 58 | + | // final pane is shortened to its content by Compose. Below 80 columns it | |
| 59 | + | // becomes a full-width bottom drawer so dialogs remain readable and usable. | |
| 60 | + | func FloatingPaneMaxRect(cols, rows int) FloatingPaneRect { | |
| 61 | + | if cols < 1 || rows < 1 { | |
| 62 | + | return FloatingPaneRect{} | |
| 63 | + | } | |
| 64 | + | height := max(1, rows*4/5) | |
| 65 | + | if rows >= 5 { | |
| 66 | + | height = max(5, height) | |
| 67 | + | } | |
| 68 | + | if height > rows { | |
| 69 | + | height = rows | |
| 70 | + | } | |
| 71 | + | if cols < 80 { | |
| 72 | + | return FloatingPaneRect{Y: rows - height, Width: cols, Height: height, Drawer: true} | |
| 73 | + | } | |
| 74 | + | width := max(6, cols*4/5) | |
| 75 | + | if width > cols { | |
| 76 | + | width = cols | |
| 77 | + | } | |
| 78 | + | return FloatingPaneRect{ | |
| 79 | + | X: (cols - width) / 2, | |
| 80 | + | Y: (rows - height) / 2, | |
| 81 | + | Width: width, | |
| 82 | + | Height: height, | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Reset discards the viewport position. Callers normally do not need this: | |
| 87 | + | // Compose resets it automatically when the view identity changes. | |
| 88 | + | func (p *FloatingPane) Reset() { | |
| 89 | + | if p == nil { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | p.id = "" | |
| 93 | + | p.scroll = 0 | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // FloatingPaneFrame contains the dimmed background and foreground rows to | |
| 97 | + | // paint at absolute terminal positions. Renderer.DrawFloating consumes it. | |
| 98 | + | type FloatingPaneFrame struct { | |
| 99 | + | Background []string | |
| 100 | + | Lines []string | |
| 101 | + | Rect FloatingPaneRect | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Compose returns a complete floating-pane frame. title is centered in the | |
| 105 | + | // pane border. contentCursorRow is relative to content and is used both to | |
| 106 | + | // retain the pane's own viewport and to return an absolute terminal cursor | |
| 107 | + | // row. Pass -1 when the foreground has no text cursor. | |
| 108 | + | func (p *FloatingPane) Compose(theme Theme, id, title string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) { | |
| 109 | + | pane.Background = DimLines(fitFloatingBackground(background, cols, rows)) | |
| 110 | + | if cols < 1 || rows < 1 { | |
| 111 | + | return pane, -1, 0 | |
| 112 | + | } | |
| 113 | + | if p == nil { | |
| 114 | + | p = &FloatingPane{} | |
| 115 | + | } | |
| 116 | + | if p.id != id { | |
| 117 | + | p.id = id | |
| 118 | + | p.scroll = 0 | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | pane.Rect = FloatingPaneMaxRect(cols, rows) | |
| 122 | + | chromeRows := 2 | |
| 123 | + | if pane.Rect.Drawer { | |
| 124 | + | chromeRows = 1 | |
| 125 | + | } | |
| 126 | + | if len(content)+chromeRows < pane.Rect.Height { | |
| 127 | + | pane.Rect.Height = max(1, len(content)+chromeRows) | |
| 128 | + | if pane.Rect.Drawer { | |
| 129 | + | pane.Rect.Y = rows - pane.Rect.Height | |
| 130 | + | } else { | |
| 131 | + | pane.Rect.Y = (rows - pane.Rect.Height) / 2 | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | tooSmall := pane.Rect.Width < 3 || (!pane.Rect.Drawer && pane.Rect.Height < 3) || (pane.Rect.Drawer && pane.Rect.Height < 2) | |
| 135 | + | if tooSmall { | |
| 136 | + | // There is no room for a reliable border. Keep the foreground usable | |
| 137 | + | // on a tiny terminal by treating the entire screen as its viewport. | |
| 138 | + | pane.Rect = FloatingPaneRect{Width: cols, Height: rows, Borderless: true} | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | viewportRows := pane.Rect.ContentHeight() | |
| 142 | + | p.follow(contentCursorRow, len(content), viewportRows) | |
| 143 | + | content = p.viewport(content, viewportRows) | |
| 144 | + | pane.Lines = floatingPaneLines(theme, pane.Rect, title, content) | |
| 145 | + | ||
| 146 | + | if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows { | |
| 147 | + | cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll | |
| 148 | + | cursorCol = pane.Rect.contentX() + max(0, contentCursorCol) | |
| 149 | + | cursorCol = min(cursorCol, pane.Rect.contentX()+pane.Rect.ContentWidth()-1) | |
| 150 | + | cursorCol = min(cursorCol, cols-1) | |
| 151 | + | return pane, cursorRow, cursorCol | |
| 152 | + | } | |
| 153 | + | return pane, -1, 0 | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (p *FloatingPane) follow(focusRow, contentRows, viewportRows int) { | |
| 157 | + | if focusRow < 0 || viewportRows < 1 { | |
| 158 | + | p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows) | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | if focusRow < p.scroll { | |
| 162 | + | p.scroll = focusRow | |
| 163 | + | } else if focusRow >= p.scroll+viewportRows { | |
| 164 | + | p.scroll = focusRow - viewportRows + 1 | |
| 165 | + | } | |
| 166 | + | p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (p *FloatingPane) viewport(content []string, rows int) []string { | |
| 170 | + | if rows < 1 { | |
| 171 | + | return nil | |
| 172 | + | } | |
| 173 | + | p.scroll = clampFloatingScroll(p.scroll, len(content), rows) | |
| 174 | + | end := min(len(content), p.scroll+rows) | |
| 175 | + | return content[p.scroll:end] | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func clampFloatingScroll(scroll, contentRows, viewportRows int) int { | |
| 179 | + | if scroll < 0 { | |
| 180 | + | return 0 | |
| 181 | + | } | |
| 182 | + | if maxScroll := max(0, contentRows-viewportRows); scroll > maxScroll { | |
| 183 | + | return maxScroll | |
| 184 | + | } | |
| 185 | + | return scroll | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func fitFloatingBackground(background []string, cols, rows int) []string { | |
| 189 | + | frame := make([]string, rows) | |
| 190 | + | if cols < 1 || rows < 1 { | |
| 191 | + | return frame | |
| 192 | + | } | |
| 193 | + | if len(background) > rows { | |
| 194 | + | background = background[len(background)-rows:] | |
| 195 | + | } | |
| 196 | + | start := rows - len(background) | |
| 197 | + | for row, line := range background { | |
| 198 | + | frame[start+row] = truncateToWidth(line, cols) | |
| 199 | + | } | |
| 200 | + | return frame | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string { | |
| 204 | + | if rect.Width < 1 || rect.Height < 1 { | |
| 205 | + | return nil | |
| 206 | + | } | |
| 207 | + | tooSmall := rect.Width < 3 || (!rect.Drawer && rect.Height < 3) || (rect.Drawer && rect.Height < 2) | |
| 208 | + | if tooSmall { | |
| 209 | + | lines := make([]string, rect.Height) | |
| 210 | + | copy(lines, content) | |
| 211 | + | return lines | |
| 212 | + | } | |
| 213 | + | lines := make([]string, rect.Height) | |
| 214 | + | if rect.Drawer { | |
| 215 | + | lines[0] = floatingPaneTitle(theme, title, rect.Width, false) | |
| 216 | + | for row := 0; row < rect.ContentHeight(); row++ { | |
| 217 | + | line := "" | |
| 218 | + | if row < len(content) { | |
| 219 | + | line = truncateToWidth(content[row], rect.ContentWidth()) | |
| 220 | + | } | |
| 221 | + | if width := visibleWidth(line); width < rect.ContentWidth() { | |
| 222 | + | line += strings.Repeat(" ", rect.ContentWidth()-width) | |
| 223 | + | } | |
| 224 | + | lines[row+1] = line | |
| 225 | + | } | |
| 226 | + | return lines | |
| 227 | + | } | |
| 228 | + | border := theme.FGColor(theme.Muted, "│") | |
| 229 | + | lines[0] = floatingPaneTitle(theme, title, rect.Width, true) | |
| 230 | + | lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯") | |
| 231 | + | for row := 0; row < rect.ContentHeight(); row++ { | |
| 232 | + | line := "" | |
| 233 | + | if row < len(content) { | |
| 234 | + | line = truncateToWidth(content[row], rect.ContentWidth()) | |
| 235 | + | } | |
| 236 | + | if width := visibleWidth(line); width < rect.ContentWidth() { | |
| 237 | + | line += strings.Repeat(" ", rect.ContentWidth()-width) | |
| 238 | + | } | |
| 239 | + | lines[row+1] = border + line + border | |
| 240 | + | } | |
| 241 | + | return lines | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func floatingPaneTitle(theme Theme, title string, width int, boxed bool) string { | |
| 245 | + | if width < 1 { | |
| 246 | + | return "" | |
| 247 | + | } | |
| 248 | + | interior := width | |
| 249 | + | left, right := "", "" | |
| 250 | + | if boxed { | |
| 251 | + | if width < 3 { | |
| 252 | + | return theme.FGColor(theme.Muted, strings.Repeat("─", width)) | |
| 253 | + | } | |
| 254 | + | interior -= 2 | |
| 255 | + | left, right = "╭", "╮" | |
| 256 | + | } | |
| 257 | + | label := "" | |
| 258 | + | if title != "" { | |
| 259 | + | label = " " + truncateToWidth(title, max(1, interior-2)) + " " | |
| 260 | + | } | |
| 261 | + | labelWidth := visibleWidth(label) | |
| 262 | + | if labelWidth > interior { | |
| 263 | + | label = truncateToWidth(label, interior) | |
| 264 | + | labelWidth = visibleWidth(label) | |
| 265 | + | } | |
| 266 | + | before := max(0, (interior-labelWidth)/2) | |
| 267 | + | after := max(0, interior-labelWidth-before) | |
| 268 | + | return theme.FGColor(theme.Muted, left+strings.Repeat("─", before)+label+strings.Repeat("─", after)+right) | |
| 269 | + | } | |
| @@ -0,0 +1,269 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // FloatingPane is a stateful viewport for an independent TUI view. It keeps | |||
| 6 | + | // its scroll position while the same view is active, follows a focused row | |||
| 7 | + | // into view, and composes that view over a live background frame. | |||
| 8 | + | // | |||
| 9 | + | // A FloatingPane is deliberately frontend-neutral: callers own their view's | |||
| 10 | + | // data and key handling, while the pane owns geometry, clipping, and visual | |||
| 11 | + | // composition. | |||
| 12 | + | type FloatingPane struct { | |||
| 13 | + | id string | |||
| 14 | + | scroll int | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // FloatingPaneRect describes a pane's outer border in zero-based terminal | |||
| 18 | + | // coordinates. Content occupies the rectangle inside that border. | |||
| 19 | + | type FloatingPaneRect struct { | |||
| 20 | + | X, Y int | |||
| 21 | + | Width, Height int | |||
| 22 | + | Drawer bool | |||
| 23 | + | Borderless bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (r FloatingPaneRect) ContentWidth() int { | |||
| 27 | + | if r.Drawer || r.Borderless { | |||
| 28 | + | return max(1, r.Width) | |||
| 29 | + | } | |||
| 30 | + | return max(1, r.Width-2) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (r FloatingPaneRect) ContentHeight() int { | |||
| 34 | + | if r.Borderless { | |||
| 35 | + | return max(1, r.Height) | |||
| 36 | + | } | |||
| 37 | + | if r.Drawer { | |||
| 38 | + | return max(1, r.Height-1) | |||
| 39 | + | } | |||
| 40 | + | return max(1, r.Height-2) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (r FloatingPaneRect) contentX() int { | |||
| 44 | + | if r.Drawer || r.Borderless { | |||
| 45 | + | return r.X | |||
| 46 | + | } | |||
| 47 | + | return r.X + 1 | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (r FloatingPaneRect) contentY() int { | |||
| 51 | + | if r.Borderless { | |||
| 52 | + | return r.Y | |||
| 53 | + | } | |||
| 54 | + | return r.Y + 1 | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // FloatingPaneMaxRect returns the largest pane for the current terminal. The | |||
| 58 | + | // final pane is shortened to its content by Compose. Below 80 columns it | |||
| 59 | + | // becomes a full-width bottom drawer so dialogs remain readable and usable. | |||
| 60 | + | func FloatingPaneMaxRect(cols, rows int) FloatingPaneRect { | |||
| 61 | + | if cols < 1 || rows < 1 { | |||
| 62 | + | return FloatingPaneRect{} | |||
| 63 | + | } | |||
| 64 | + | height := max(1, rows*4/5) | |||
| 65 | + | if rows >= 5 { | |||
| 66 | + | height = max(5, height) | |||
| 67 | + | } | |||
| 68 | + | if height > rows { | |||
| 69 | + | height = rows | |||
| 70 | + | } | |||
| 71 | + | if cols < 80 { | |||
| 72 | + | return FloatingPaneRect{Y: rows - height, Width: cols, Height: height, Drawer: true} | |||
| 73 | + | } | |||
| 74 | + | width := max(6, cols*4/5) | |||
| 75 | + | if width > cols { | |||
| 76 | + | width = cols | |||
| 77 | + | } | |||
| 78 | + | return FloatingPaneRect{ | |||
| 79 | + | X: (cols - width) / 2, | |||
| 80 | + | Y: (rows - height) / 2, | |||
| 81 | + | Width: width, | |||
| 82 | + | Height: height, | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Reset discards the viewport position. Callers normally do not need this: | |||
| 87 | + | // Compose resets it automatically when the view identity changes. | |||
| 88 | + | func (p *FloatingPane) Reset() { | |||
| 89 | + | if p == nil { | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 92 | + | p.id = "" | |||
| 93 | + | p.scroll = 0 | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // FloatingPaneFrame contains the dimmed background and foreground rows to | |||
| 97 | + | // paint at absolute terminal positions. Renderer.DrawFloating consumes it. | |||
| 98 | + | type FloatingPaneFrame struct { | |||
| 99 | + | Background []string | |||
| 100 | + | Lines []string | |||
| 101 | + | Rect FloatingPaneRect | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Compose returns a complete floating-pane frame. title is centered in the | |||
| 105 | + | // pane border. contentCursorRow is relative to content and is used both to | |||
| 106 | + | // retain the pane's own viewport and to return an absolute terminal cursor | |||
| 107 | + | // row. Pass -1 when the foreground has no text cursor. | |||
| 108 | + | func (p *FloatingPane) Compose(theme Theme, id, title string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) { | |||
| 109 | + | pane.Background = DimLines(fitFloatingBackground(background, cols, rows)) | |||
| 110 | + | if cols < 1 || rows < 1 { | |||
| 111 | + | return pane, -1, 0 | |||
| 112 | + | } | |||
| 113 | + | if p == nil { | |||
| 114 | + | p = &FloatingPane{} | |||
| 115 | + | } | |||
| 116 | + | if p.id != id { | |||
| 117 | + | p.id = id | |||
| 118 | + | p.scroll = 0 | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | pane.Rect = FloatingPaneMaxRect(cols, rows) | |||
| 122 | + | chromeRows := 2 | |||
| 123 | + | if pane.Rect.Drawer { | |||
| 124 | + | chromeRows = 1 | |||
| 125 | + | } | |||
| 126 | + | if len(content)+chromeRows < pane.Rect.Height { | |||
| 127 | + | pane.Rect.Height = max(1, len(content)+chromeRows) | |||
| 128 | + | if pane.Rect.Drawer { | |||
| 129 | + | pane.Rect.Y = rows - pane.Rect.Height | |||
| 130 | + | } else { | |||
| 131 | + | pane.Rect.Y = (rows - pane.Rect.Height) / 2 | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | tooSmall := pane.Rect.Width < 3 || (!pane.Rect.Drawer && pane.Rect.Height < 3) || (pane.Rect.Drawer && pane.Rect.Height < 2) | |||
| 135 | + | if tooSmall { | |||
| 136 | + | // There is no room for a reliable border. Keep the foreground usable | |||
| 137 | + | // on a tiny terminal by treating the entire screen as its viewport. | |||
| 138 | + | pane.Rect = FloatingPaneRect{Width: cols, Height: rows, Borderless: true} | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | viewportRows := pane.Rect.ContentHeight() | |||
| 142 | + | p.follow(contentCursorRow, len(content), viewportRows) | |||
| 143 | + | content = p.viewport(content, viewportRows) | |||
| 144 | + | pane.Lines = floatingPaneLines(theme, pane.Rect, title, content) | |||
| 145 | + | ||||
| 146 | + | if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows { | |||
| 147 | + | cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll | |||
| 148 | + | cursorCol = pane.Rect.contentX() + max(0, contentCursorCol) | |||
| 149 | + | cursorCol = min(cursorCol, pane.Rect.contentX()+pane.Rect.ContentWidth()-1) | |||
| 150 | + | cursorCol = min(cursorCol, cols-1) | |||
| 151 | + | return pane, cursorRow, cursorCol | |||
| 152 | + | } | |||
| 153 | + | return pane, -1, 0 | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (p *FloatingPane) follow(focusRow, contentRows, viewportRows int) { | |||
| 157 | + | if focusRow < 0 || viewportRows < 1 { | |||
| 158 | + | p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows) | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | if focusRow < p.scroll { | |||
| 162 | + | p.scroll = focusRow | |||
| 163 | + | } else if focusRow >= p.scroll+viewportRows { | |||
| 164 | + | p.scroll = focusRow - viewportRows + 1 | |||
| 165 | + | } | |||
| 166 | + | p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (p *FloatingPane) viewport(content []string, rows int) []string { | |||
| 170 | + | if rows < 1 { | |||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | p.scroll = clampFloatingScroll(p.scroll, len(content), rows) | |||
| 174 | + | end := min(len(content), p.scroll+rows) | |||
| 175 | + | return content[p.scroll:end] | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func clampFloatingScroll(scroll, contentRows, viewportRows int) int { | |||
| 179 | + | if scroll < 0 { | |||
| 180 | + | return 0 | |||
| 181 | + | } | |||
| 182 | + | if maxScroll := max(0, contentRows-viewportRows); scroll > maxScroll { | |||
| 183 | + | return maxScroll | |||
| 184 | + | } | |||
| 185 | + | return scroll | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func fitFloatingBackground(background []string, cols, rows int) []string { | |||
| 189 | + | frame := make([]string, rows) | |||
| 190 | + | if cols < 1 || rows < 1 { | |||
| 191 | + | return frame | |||
| 192 | + | } | |||
| 193 | + | if len(background) > rows { | |||
| 194 | + | background = background[len(background)-rows:] | |||
| 195 | + | } | |||
| 196 | + | start := rows - len(background) | |||
| 197 | + | for row, line := range background { | |||
| 198 | + | frame[start+row] = truncateToWidth(line, cols) | |||
| 199 | + | } | |||
| 200 | + | return frame | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string { | |||
| 204 | + | if rect.Width < 1 || rect.Height < 1 { | |||
| 205 | + | return nil | |||
| 206 | + | } | |||
| 207 | + | tooSmall := rect.Width < 3 || (!rect.Drawer && rect.Height < 3) || (rect.Drawer && rect.Height < 2) | |||
| 208 | + | if tooSmall { | |||
| 209 | + | lines := make([]string, rect.Height) | |||
| 210 | + | copy(lines, content) | |||
| 211 | + | return lines | |||
| 212 | + | } | |||
| 213 | + | lines := make([]string, rect.Height) | |||
| 214 | + | if rect.Drawer { | |||
| 215 | + | lines[0] = floatingPaneTitle(theme, title, rect.Width, false) | |||
| 216 | + | for row := 0; row < rect.ContentHeight(); row++ { | |||
| 217 | + | line := "" | |||
| 218 | + | if row < len(content) { | |||
| 219 | + | line = truncateToWidth(content[row], rect.ContentWidth()) | |||
| 220 | + | } | |||
| 221 | + | if width := visibleWidth(line); width < rect.ContentWidth() { | |||
| 222 | + | line += strings.Repeat(" ", rect.ContentWidth()-width) | |||
| 223 | + | } | |||
| 224 | + | lines[row+1] = line | |||
| 225 | + | } | |||
| 226 | + | return lines | |||
| 227 | + | } | |||
| 228 | + | border := theme.FGColor(theme.Muted, "│") | |||
| 229 | + | lines[0] = floatingPaneTitle(theme, title, rect.Width, true) | |||
| 230 | + | lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯") | |||
| 231 | + | for row := 0; row < rect.ContentHeight(); row++ { | |||
| 232 | + | line := "" | |||
| 233 | + | if row < len(content) { | |||
| 234 | + | line = truncateToWidth(content[row], rect.ContentWidth()) | |||
| 235 | + | } | |||
| 236 | + | if width := visibleWidth(line); width < rect.ContentWidth() { | |||
| 237 | + | line += strings.Repeat(" ", rect.ContentWidth()-width) | |||
| 238 | + | } | |||
| 239 | + | lines[row+1] = border + line + border | |||
| 240 | + | } | |||
| 241 | + | return lines | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func floatingPaneTitle(theme Theme, title string, width int, boxed bool) string { | |||
| 245 | + | if width < 1 { | |||
| 246 | + | return "" | |||
| 247 | + | } | |||
| 248 | + | interior := width | |||
| 249 | + | left, right := "", "" | |||
| 250 | + | if boxed { | |||
| 251 | + | if width < 3 { | |||
| 252 | + | return theme.FGColor(theme.Muted, strings.Repeat("─", width)) | |||
| 253 | + | } | |||
| 254 | + | interior -= 2 | |||
| 255 | + | left, right = "╭", "╮" | |||
| 256 | + | } | |||
| 257 | + | label := "" | |||
| 258 | + | if title != "" { | |||
| 259 | + | label = " " + truncateToWidth(title, max(1, interior-2)) + " " | |||
| 260 | + | } | |||
| 261 | + | labelWidth := visibleWidth(label) | |||
| 262 | + | if labelWidth > interior { | |||
| 263 | + | label = truncateToWidth(label, interior) | |||
| 264 | + | labelWidth = visibleWidth(label) | |||
| 265 | + | } | |||
| 266 | + | before := max(0, (interior-labelWidth)/2) | |||
| 267 | + | after := max(0, interior-labelWidth-before) | |||
| 268 | + | return theme.FGColor(theme.Muted, left+strings.Repeat("─", before)+label+strings.Repeat("─", after)+right) | |||
| 269 | + | } | |||
A packages/tui/floating_pane_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | |
| 9 | + | var pane FloatingPane | |
| 10 | + | frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0) | |
| 11 | + | ||
| 12 | + | if frame.Rect.Drawer { | |
| 13 | + | t.Fatal("100-column terminal unexpectedly used drawer layout") | |
| 14 | + | } | |
| 15 | + | if want := (100 - frame.Rect.Width) / 2; frame.Rect.X != want { | |
| 16 | + | t.Fatalf("pane x = %d, want %d", frame.Rect.X, want) | |
| 17 | + | } | |
| 18 | + | if !strings.Contains(frame.Background[len(frame.Background)-2], Dim("live transcript")) { | |
| 19 | + | t.Fatalf("live background was not dimmed: %q", frame.Background) | |
| 20 | + | } | |
| 21 | + | if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") { | |
| 22 | + | t.Fatalf("pane is missing top border: %q", got) | |
| 23 | + | } else if title := strings.Index(got, "settings"); title < 0 { | |
| 24 | + | t.Fatalf("pane title is missing from its border: %q", got) | |
| 25 | + | } else { | |
| 26 | + | left := strings.Count(got[:title], "─") | |
| 27 | + | right := strings.Count(got[title+len("settings"):], "─") | |
| 28 | + | if left-right > 1 || right-left > 1 { | |
| 29 | + | t.Fatalf("pane title is not centered in its border: %q", got) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | if cursorRow != -1 || cursorCol != 0 { | |
| 33 | + | t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | |
| 38 | + | var pane FloatingPane | |
| 39 | + | frame, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"settings body"}, 79, 24, -1, 0) | |
| 40 | + | ||
| 41 | + | if !frame.Rect.Drawer { | |
| 42 | + | t.Fatal("79-column terminal did not use drawer layout") | |
| 43 | + | } | |
| 44 | + | if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 { | |
| 45 | + | t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect) | |
| 46 | + | } | |
| 47 | + | if got := stripANSI(frame.Lines[0]); !strings.Contains(got, "settings") { | |
| 48 | + | t.Fatalf("drawer title = %q, want centered settings title", got) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | |
| 53 | + | var pane FloatingPane | |
| 54 | + | wide, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 100, 30, -1, 0) | |
| 55 | + | narrow, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 79, 24, -1, 0) | |
| 56 | + | ||
| 57 | + | if wide.Rect.Drawer { | |
| 58 | + | t.Fatal("wide terminal unexpectedly used a drawer") | |
| 59 | + | } | |
| 60 | + | if !narrow.Rect.Drawer || narrow.Rect.Width != 79 || narrow.Rect.Y+narrow.Rect.Height != 24 { | |
| 61 | + | t.Fatalf("resize did not recompute a bottom drawer: %+v", narrow.Rect) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) { | |
| 66 | + | var pane FloatingPane | |
| 67 | + | frame, _, col := pane.Compose(Dark, "search", "search", nil, []string{"query"}, 100, 20, 0, 1000) | |
| 68 | + | if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want { | |
| 69 | + | t.Fatalf("cursor col = %d, want content edge %d", col, want) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | tiny, row, tinyCol := pane.Compose(Dark, "search", "search", nil, []string{"x"}, 2, 2, 0, 10) | |
| 73 | + | if !tiny.Rect.Borderless { | |
| 74 | + | t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect) | |
| 75 | + | } | |
| 76 | + | if row != 0 || tinyCol != 1 { | |
| 77 | + | t.Fatalf("tiny cursor = (%d, %d), want (0, 1)", row, tinyCol) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.T) { | |
| 82 | + | content := make([]string, 30) | |
| 83 | + | for row := range content { | |
| 84 | + | content[row] = "row " + itoa(row) | |
| 85 | + | } | |
| 86 | + | var pane FloatingPane | |
| 87 | + | frame, cursorRow, _ := pane.Compose(Dark, "settings", "settings", nil, content, 100, 12, 25, 0) | |
| 88 | + | if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 { | |
| 89 | + | t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect) | |
| 90 | + | } | |
| 91 | + | if !strings.Contains(stripANSI(strings.Join(frame.Lines, "\n")), "row 25") { | |
| 92 | + | t.Fatal("focused row was not retained in the pane viewport") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | other, _, _ := pane.Compose(Dark, "subagents", "subagents", nil, content, 100, 12, -1, 0) | |
| 96 | + | if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") { | |
| 97 | + | t.Fatal("new view inherited the previous view's scroll position") | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testing.T) { | |
| 102 | + | var out strings.Builder | |
| 103 | + | r := NewRenderer(&out) | |
| 104 | + | r.Resize(100, 20) | |
| 105 | + | var pane FloatingPane | |
| 106 | + | frame, _, _ := pane.Compose(Dark, "settings", "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0) | |
| 107 | + | r.DrawFloating(frame, -1, 0) | |
| 108 | + | got := out.String() | |
| 109 | + | if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") { | |
| 110 | + | t.Fatalf("floating render omitted a layer: %q", got) | |
| 111 | + | } | |
| 112 | + | if !strings.Contains(got, MoveTo(frame.Rect.Y+1, frame.Rect.X+1)) { | |
| 113 | + | t.Fatalf("floating pane was not painted at its geometry: %q", got) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | out.Reset() | |
| 117 | + | r.Draw(frame.Background, -1, 0) | |
| 118 | + | if !strings.Contains(out.String(), MoveTo(1, 1)) { | |
| 119 | + | t.Fatalf("normal frame did not repaint after a floating pane: %q", out.String()) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestRendererDrawFloatingPreservesThemeBackgroundAndImageInvalidation(t *testing.T) { | |
| 124 | + | var out strings.Builder | |
| 125 | + | r := NewRenderer(&out) | |
| 126 | + | background := Color256(237) | |
| 127 | + | th := Dark | |
| 128 | + | th.Background = &background | |
| 129 | + | r.SetTheme(th) | |
| 130 | + | r.Resize(20, 4) | |
| 131 | + | ||
| 132 | + | pane := FloatingPaneFrame{ | |
| 133 | + | Background: make([]string, 4), | |
| 134 | + | Lines: []string{"\x1b_Ga=T,f=100;image\x1b\\"}, | |
| 135 | + | Rect: FloatingPaneRect{X: 2, Y: 1, Width: 10, Height: 1, Borderless: true}, | |
| 136 | + | } | |
| 137 | + | r.DrawFloating(pane, -1, 0) | |
| 138 | + | if !strings.Contains(out.String(), th.BackgroundStyle()) { | |
| 139 | + | t.Fatalf("floating pane omitted theme background: %q", out.String()) | |
| 140 | + | } | |
| 141 | + | if !r.prevHadImage || !r.prevHadKittyImage { | |
| 142 | + | t.Fatalf("floating image state = image:%t kitty:%t, want both true", r.prevHadImage, r.prevHadKittyImage) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | out.Reset() | |
| 146 | + | r.Draw([]string{""}, -1, 0) | |
| 147 | + | if !strings.Contains(out.String(), SeqDeleteKittyImages) { | |
| 148 | + | t.Fatalf("stale kitty image was not deleted: %q", out.String()) | |
| 149 | + | } | |
| 150 | + | } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) { | |||
| 9 | + | var pane FloatingPane | |||
| 10 | + | frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0) | |||
| 11 | + | ||||
| 12 | + | if frame.Rect.Drawer { | |||
| 13 | + | t.Fatal("100-column terminal unexpectedly used drawer layout") | |||
| 14 | + | } | |||
| 15 | + | if want := (100 - frame.Rect.Width) / 2; frame.Rect.X != want { | |||
| 16 | + | t.Fatalf("pane x = %d, want %d", frame.Rect.X, want) | |||
| 17 | + | } | |||
| 18 | + | if !strings.Contains(frame.Background[len(frame.Background)-2], Dim("live transcript")) { | |||
| 19 | + | t.Fatalf("live background was not dimmed: %q", frame.Background) | |||
| 20 | + | } | |||
| 21 | + | if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") { | |||
| 22 | + | t.Fatalf("pane is missing top border: %q", got) | |||
| 23 | + | } else if title := strings.Index(got, "settings"); title < 0 { | |||
| 24 | + | t.Fatalf("pane title is missing from its border: %q", got) | |||
| 25 | + | } else { | |||
| 26 | + | left := strings.Count(got[:title], "─") | |||
| 27 | + | right := strings.Count(got[title+len("settings"):], "─") | |||
| 28 | + | if left-right > 1 || right-left > 1 { | |||
| 29 | + | t.Fatalf("pane title is not centered in its border: %q", got) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | if cursorRow != -1 || cursorCol != 0 { | |||
| 33 | + | t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | |||
| 38 | + | var pane FloatingPane | |||
| 39 | + | frame, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"settings body"}, 79, 24, -1, 0) | |||
| 40 | + | ||||
| 41 | + | if !frame.Rect.Drawer { | |||
| 42 | + | t.Fatal("79-column terminal did not use drawer layout") | |||
| 43 | + | } | |||
| 44 | + | if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 { | |||
| 45 | + | t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect) | |||
| 46 | + | } | |||
| 47 | + | if got := stripANSI(frame.Lines[0]); !strings.Contains(got, "settings") { | |||
| 48 | + | t.Fatalf("drawer title = %q, want centered settings title", got) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | |||
| 53 | + | var pane FloatingPane | |||
| 54 | + | wide, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 100, 30, -1, 0) | |||
| 55 | + | narrow, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 79, 24, -1, 0) | |||
| 56 | + | ||||
| 57 | + | if wide.Rect.Drawer { | |||
| 58 | + | t.Fatal("wide terminal unexpectedly used a drawer") | |||
| 59 | + | } | |||
| 60 | + | if !narrow.Rect.Drawer || narrow.Rect.Width != 79 || narrow.Rect.Y+narrow.Rect.Height != 24 { | |||
| 61 | + | t.Fatalf("resize did not recompute a bottom drawer: %+v", narrow.Rect) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) { | |||
| 66 | + | var pane FloatingPane | |||
| 67 | + | frame, _, col := pane.Compose(Dark, "search", "search", nil, []string{"query"}, 100, 20, 0, 1000) | |||
| 68 | + | if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want { | |||
| 69 | + | t.Fatalf("cursor col = %d, want content edge %d", col, want) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | tiny, row, tinyCol := pane.Compose(Dark, "search", "search", nil, []string{"x"}, 2, 2, 0, 10) | |||
| 73 | + | if !tiny.Rect.Borderless { | |||
| 74 | + | t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect) | |||
| 75 | + | } | |||
| 76 | + | if row != 0 || tinyCol != 1 { | |||
| 77 | + | t.Fatalf("tiny cursor = (%d, %d), want (0, 1)", row, tinyCol) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.T) { | |||
| 82 | + | content := make([]string, 30) | |||
| 83 | + | for row := range content { | |||
| 84 | + | content[row] = "row " + itoa(row) | |||
| 85 | + | } | |||
| 86 | + | var pane FloatingPane | |||
| 87 | + | frame, cursorRow, _ := pane.Compose(Dark, "settings", "settings", nil, content, 100, 12, 25, 0) | |||
| 88 | + | if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 { | |||
| 89 | + | t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect) | |||
| 90 | + | } | |||
| 91 | + | if !strings.Contains(stripANSI(strings.Join(frame.Lines, "\n")), "row 25") { | |||
| 92 | + | t.Fatal("focused row was not retained in the pane viewport") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | other, _, _ := pane.Compose(Dark, "subagents", "subagents", nil, content, 100, 12, -1, 0) | |||
| 96 | + | if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") { | |||
| 97 | + | t.Fatal("new view inherited the previous view's scroll position") | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testing.T) { | |||
| 102 | + | var out strings.Builder | |||
| 103 | + | r := NewRenderer(&out) | |||
| 104 | + | r.Resize(100, 20) | |||
| 105 | + | var pane FloatingPane | |||
| 106 | + | frame, _, _ := pane.Compose(Dark, "settings", "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0) | |||
| 107 | + | r.DrawFloating(frame, -1, 0) | |||
| 108 | + | got := out.String() | |||
| 109 | + | if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") { | |||
| 110 | + | t.Fatalf("floating render omitted a layer: %q", got) | |||
| 111 | + | } | |||
| 112 | + | if !strings.Contains(got, MoveTo(frame.Rect.Y+1, frame.Rect.X+1)) { | |||
| 113 | + | t.Fatalf("floating pane was not painted at its geometry: %q", got) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | out.Reset() | |||
| 117 | + | r.Draw(frame.Background, -1, 0) | |||
| 118 | + | if !strings.Contains(out.String(), MoveTo(1, 1)) { | |||
| 119 | + | t.Fatalf("normal frame did not repaint after a floating pane: %q", out.String()) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestRendererDrawFloatingPreservesThemeBackgroundAndImageInvalidation(t *testing.T) { | |||
| 124 | + | var out strings.Builder | |||
| 125 | + | r := NewRenderer(&out) | |||
| 126 | + | background := Color256(237) | |||
| 127 | + | th := Dark | |||
| 128 | + | th.Background = &background | |||
| 129 | + | r.SetTheme(th) | |||
| 130 | + | r.Resize(20, 4) | |||
| 131 | + | ||||
| 132 | + | pane := FloatingPaneFrame{ | |||
| 133 | + | Background: make([]string, 4), | |||
| 134 | + | Lines: []string{"\x1b_Ga=T,f=100;image\x1b\\"}, | |||
| 135 | + | Rect: FloatingPaneRect{X: 2, Y: 1, Width: 10, Height: 1, Borderless: true}, | |||
| 136 | + | } | |||
| 137 | + | r.DrawFloating(pane, -1, 0) | |||
| 138 | + | if !strings.Contains(out.String(), th.BackgroundStyle()) { | |||
| 139 | + | t.Fatalf("floating pane omitted theme background: %q", out.String()) | |||
| 140 | + | } | |||
| 141 | + | if !r.prevHadImage || !r.prevHadKittyImage { | |||
| 142 | + | t.Fatalf("floating image state = image:%t kitty:%t, want both true", r.prevHadImage, r.prevHadKittyImage) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | out.Reset() | |||
| 146 | + | r.Draw([]string{""}, -1, 0) | |||
| 147 | + | if !strings.Contains(out.String(), SeqDeleteKittyImages) { | |||
| 148 | + | t.Fatalf("stale kitty image was not deleted: %q", out.String()) | |||
| 149 | + | } | |||
| 150 | + | } | |||
M packages/tui/render.go +73 -8
| @@ -27,9 +27,13 @@ type Renderer struct { | |||
| 27 | 27 | ||
| 28 | 28 | // prevHadImage tracks whether the previous frame contained an | |
| 29 | 29 | // inline-image escape so we can force a full clear+repaint whenever | |
| 30 | - | // the image set changes. Only matters when inline images are | |
| 31 | - | // enabled via ZUT_INLINE_IMAGES; defaults to false. | |
| 32 | - | prevHadImage bool | |
| 30 | + | // the image set changes. prevHadKittyImage additionally ensures stale | |
| 31 | + | // Kitty image pixels are explicitly deleted when their layer disappears. | |
| 32 | + | prevHadImage bool | |
| 33 | + | prevHadKittyImage bool | |
| 34 | + | floatingActive bool | |
| 35 | + | drawingFloating bool | |
| 36 | + | floatingRect FloatingPaneRect | |
| 33 | 37 | ||
| 34 | 38 | // Main-screen flow renderer state. logLines is the full logical | |
| 35 | 39 | // buffer (chat + live bottom band) from the previous DrawLog call. | |
| @@ -415,6 +419,10 @@ func truncateToWidth(s string, cols int) string { | |||
| 415 | 419 | } | |
| 416 | 420 | ||
| 417 | 421 | func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |
| 422 | + | if r.floatingActive && !r.drawingFloating { | |
| 423 | + | r.Invalidate() | |
| 424 | + | r.floatingActive = false | |
| 425 | + | } | |
| 418 | 426 | if r.cols == 0 || r.rows == 0 { | |
| 419 | 427 | return | |
| 420 | 428 | } | |
| @@ -468,11 +476,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 468 | 476 | // its scrollbar to the top of the viewport on every image or | |
| 469 | 477 | // selection-highlight frame. | |
| 470 | 478 | w.WriteString(SeqClearScreenNoHome) | |
| 471 | - | if curHasKittyImage { | |
| 472 | - | // Delete previously placed kitty images once per frame, | |
| 473 | - | // before rewriting all rows. Doing this inside each image | |
| 474 | - | // escape makes only the last image in the frame survive. | |
| 475 | - | w.WriteString("\x1b_Ga=d\x1b\\") | |
| 479 | + | if curHasKittyImage || r.prevHadKittyImage { | |
| 480 | + | // Delete previously placed kitty images once per frame, before | |
| 481 | + | // rewriting all rows. This also removes a former foreground image | |
| 482 | + | // when the replacement frame contains no Kitty image escape. | |
| 483 | + | w.WriteString(SeqDeleteKittyImages) | |
| 476 | 484 | } | |
| 477 | 485 | } | |
| 478 | 486 | ||
| @@ -523,10 +531,67 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 523 | 531 | ||
| 524 | 532 | r.prev = frame | |
| 525 | 533 | r.prevHadImage = curHasImage | |
| 534 | + | r.prevHadKittyImage = curHasKittyImage | |
| 526 | 535 | r.cursorRow = cursorRow | |
| 527 | 536 | r.cursorCol = cursorCol | |
| 528 | 537 | } | |
| 529 | 538 | ||
| 539 | + | // DrawFloating paints a complete, dimmed live background and then an opaque | |
| 540 | + | // floating pane at absolute coordinates. The background is deliberately | |
| 541 | + | // invalidated on every call: the pane is not represented in Draw's frame cache, | |
| 542 | + | // so repainting it first prevents stale foreground cells when either layer | |
| 543 | + | // changes or the terminal is resized. | |
| 544 | + | func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int) { | |
| 545 | + | if r.cols == 0 || r.rows == 0 { | |
| 546 | + | return | |
| 547 | + | } | |
| 548 | + | if !r.floatingActive || r.floatingRect != pane.Rect { | |
| 549 | + | r.Invalidate() | |
| 550 | + | } | |
| 551 | + | r.drawingFloating = true | |
| 552 | + | r.Draw(pane.Background, -1, 0) | |
| 553 | + | r.drawingFloating = false | |
| 554 | + | ||
| 555 | + | paneHasImage := false | |
| 556 | + | paneHasKittyImage := false | |
| 557 | + | for _, line := range pane.Lines { | |
| 558 | + | if containsImageEscape(line) { | |
| 559 | + | paneHasImage = true | |
| 560 | + | if strings.Contains(line, "\x1b_G") { | |
| 561 | + | paneHasKittyImage = true | |
| 562 | + | } | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | var w strings.Builder | |
| 567 | + | w.WriteString(SeqSynchronizedOn) | |
| 568 | + | w.WriteString(SeqHideCursor) | |
| 569 | + | for row, line := range pane.Lines { | |
| 570 | + | y := pane.Rect.Y + row | |
| 571 | + | if y < 0 || y >= r.rows { | |
| 572 | + | continue | |
| 573 | + | } | |
| 574 | + | if pane.Rect.X >= r.cols { | |
| 575 | + | continue | |
| 576 | + | } | |
| 577 | + | width := min(pane.Rect.Width, r.cols-pane.Rect.X) | |
| 578 | + | w.WriteString(MoveTo(y+1, pane.Rect.X+1)) | |
| 579 | + | w.WriteString("\x1b[0m") | |
| 580 | + | w.WriteString(paintBackgroundRow(truncateToWidth(line, width), width, r.backgroundStyle)) | |
| 581 | + | } | |
| 582 | + | if cursorRow >= 0 && cursorRow < r.rows && cursorCol >= 0 && cursorCol < r.cols { | |
| 583 | + | w.WriteString(MoveTo(cursorRow+1, cursorCol+1)) | |
| 584 | + | w.WriteString(SeqShowCursor) | |
| 585 | + | } | |
| 586 | + | w.WriteString(SeqSynchronizedOff) | |
| 587 | + | _, _ = io.WriteString(r.out, w.String()) | |
| 588 | + | r.prevHadImage = r.prevHadImage || paneHasImage | |
| 589 | + | r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage | |
| 590 | + | // Keep the dimmed background cache while this geometry remains active. | |
| 591 | + | r.floatingActive = true | |
| 592 | + | r.floatingRect = pane.Rect | |
| 593 | + | } | |
| 594 | + | ||
| 530 | 595 | // DrawLog renders zut in the terminal's main screen as normal terminal | |
| 531 | 596 | // flow rather than a fixed full-screen frame. Chat lines are emitted once | |
| 532 | 597 | // into the host terminal scrollback; the current bottom block (dialogs, | |
| @@ -27,9 +27,13 @@ type Renderer struct { | |||||
| 27 | 27 | ||||
| 28 | // prevHadImage tracks whether the previous frame contained an | 28 | // prevHadImage tracks whether the previous frame contained an | ||
| 29 | // inline-image escape so we can force a full clear+repaint whenever | 29 | // inline-image escape so we can force a full clear+repaint whenever | ||
| 30 | - | // the image set changes. Only matters when inline images are | 30 | + | // the image set changes. prevHadKittyImage additionally ensures stale |
| 31 | - | // enabled via ZUT_INLINE_IMAGES; defaults to false. | 31 | + | // Kitty image pixels are explicitly deleted when their layer disappears. |
| 32 | - | prevHadImage bool | 32 | + | prevHadImage bool |
| 33 | + | prevHadKittyImage bool | |||
| 34 | + | floatingActive bool | |||
| 35 | + | drawingFloating bool | |||
| 36 | + | floatingRect FloatingPaneRect | |||
| 33 | 37 | ||||
| 34 | // Main-screen flow renderer state. logLines is the full logical | 38 | // Main-screen flow renderer state. logLines is the full logical | ||
| 35 | // buffer (chat + live bottom band) from the previous DrawLog call. | 39 | // buffer (chat + live bottom band) from the previous DrawLog call. | ||
| @@ -415,6 +419,10 @@ func truncateToWidth(s string, cols int) string { | |||||
| 415 | } | 419 | } | ||
| 416 | 420 | ||||
| 417 | 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 | + | } | |||
| 418 | if r.cols == 0 || r.rows == 0 { | 426 | if r.cols == 0 || r.rows == 0 { | ||
| 419 | return | 427 | return | ||
| 420 | } | 428 | } | ||
| @@ -468,11 +476,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 468 | // its scrollbar to the top of the viewport on every image or | 476 | // its scrollbar to the top of the viewport on every image or | ||
| 469 | // selection-highlight frame. | 477 | // selection-highlight frame. | ||
| 470 | w.WriteString(SeqClearScreenNoHome) | 478 | w.WriteString(SeqClearScreenNoHome) | ||
| 471 | - | if curHasKittyImage { | 479 | + | if curHasKittyImage || r.prevHadKittyImage { |
| 472 | - | // Delete previously placed kitty images once per frame, | 480 | + | // Delete previously placed kitty images once per frame, before |
| 473 | - | // before rewriting all rows. Doing this inside each image | 481 | + | // rewriting all rows. This also removes a former foreground image |
| 474 | - | // escape makes only the last image in the frame survive. | 482 | + | // when the replacement frame contains no Kitty image escape. |
| 475 | - | w.WriteString("\x1b_Ga=d\x1b\\") | 483 | + | w.WriteString(SeqDeleteKittyImages) |
| 476 | } | 484 | } | ||
| 477 | } | 485 | } | ||
| 478 | 486 | ||||
| @@ -523,10 +531,67 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 523 | 531 | ||||
| 524 | r.prev = frame | 532 | r.prev = frame | ||
| 525 | r.prevHadImage = curHasImage | 533 | r.prevHadImage = curHasImage | ||
| 534 | + | r.prevHadKittyImage = curHasKittyImage | |||
| 526 | r.cursorRow = cursorRow | 535 | r.cursorRow = cursorRow | ||
| 527 | r.cursorCol = cursorCol | 536 | r.cursorCol = cursorCol | ||
| 528 | } | 537 | } | ||
| 529 | 538 | ||||
| 539 | + | // DrawFloating paints a complete, dimmed live background and then an opaque | |||
| 540 | + | // floating pane at absolute coordinates. The background is deliberately | |||
| 541 | + | // invalidated on every call: the pane is not represented in Draw's frame cache, | |||
| 542 | + | // so repainting it first prevents stale foreground cells when either layer | |||
| 543 | + | // changes or the terminal is resized. | |||
| 544 | + | func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int) { | |||
| 545 | + | if r.cols == 0 || r.rows == 0 { | |||
| 546 | + | return | |||
| 547 | + | } | |||
| 548 | + | if !r.floatingActive || r.floatingRect != pane.Rect { | |||
| 549 | + | r.Invalidate() | |||
| 550 | + | } | |||
| 551 | + | r.drawingFloating = true | |||
| 552 | + | r.Draw(pane.Background, -1, 0) | |||
| 553 | + | r.drawingFloating = false | |||
| 554 | + | ||||
| 555 | + | paneHasImage := false | |||
| 556 | + | paneHasKittyImage := false | |||
| 557 | + | for _, line := range pane.Lines { | |||
| 558 | + | if containsImageEscape(line) { | |||
| 559 | + | paneHasImage = true | |||
| 560 | + | if strings.Contains(line, "\x1b_G") { | |||
| 561 | + | paneHasKittyImage = true | |||
| 562 | + | } | |||
| 563 | + | } | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | var w strings.Builder | |||
| 567 | + | w.WriteString(SeqSynchronizedOn) | |||
| 568 | + | w.WriteString(SeqHideCursor) | |||
| 569 | + | for row, line := range pane.Lines { | |||
| 570 | + | y := pane.Rect.Y + row | |||
| 571 | + | if y < 0 || y >= r.rows { | |||
| 572 | + | continue | |||
| 573 | + | } | |||
| 574 | + | if pane.Rect.X >= r.cols { | |||
| 575 | + | continue | |||
| 576 | + | } | |||
| 577 | + | width := min(pane.Rect.Width, r.cols-pane.Rect.X) | |||
| 578 | + | w.WriteString(MoveTo(y+1, pane.Rect.X+1)) | |||
| 579 | + | w.WriteString("\x1b[0m") | |||
| 580 | + | w.WriteString(paintBackgroundRow(truncateToWidth(line, width), width, r.backgroundStyle)) | |||
| 581 | + | } | |||
| 582 | + | if cursorRow >= 0 && cursorRow < r.rows && cursorCol >= 0 && cursorCol < r.cols { | |||
| 583 | + | w.WriteString(MoveTo(cursorRow+1, cursorCol+1)) | |||
| 584 | + | w.WriteString(SeqShowCursor) | |||
| 585 | + | } | |||
| 586 | + | w.WriteString(SeqSynchronizedOff) | |||
| 587 | + | _, _ = io.WriteString(r.out, w.String()) | |||
| 588 | + | r.prevHadImage = r.prevHadImage || paneHasImage | |||
| 589 | + | r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage | |||
| 590 | + | // Keep the dimmed background cache while this geometry remains active. | |||
| 591 | + | r.floatingActive = true | |||
| 592 | + | r.floatingRect = pane.Rect | |||
| 593 | + | } | |||
| 594 | + | ||||
| 530 | // DrawLog renders zut in the terminal's main screen as normal terminal | 595 | // DrawLog renders zut in the terminal's main screen as normal terminal | ||
| 531 | // flow rather than a fixed full-screen frame. Chat lines are emitted once | 596 | // flow rather than a fixed full-screen frame. Chat lines are emitted once | ||
| 532 | // into the host terminal scrollback; the current bottom block (dialogs, | 597 | // into the host terminal scrollback; the current bottom block (dialogs, | ||