feat(tui): add floating panes
- commit
- 9e66d9a650df0d5b78ec77ee0bae9e5172327b2f
- author
- brice · · 2026-08-14 08:07 UTC
- committer
- brice · · 2026-08-14 08:46 UTC
- parent
- 0ee5de5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +581 -95
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 +1 -0
| @@ -780,6 +780,7 @@ type Interactive struct { | |||
| 780 | 780 | logoutDialog *logoutDialog | |
| 781 | 781 | telegramDialog *telegramDialog | |
| 782 | 782 | settingsDialog *settingsDialog | |
| 783 | + | floatingPane tui.FloatingPane | |
| 783 | 784 | quickModelAssign int | |
| 784 | 785 | telegramBridge *telegram.Bridge | |
| 785 | 786 | sessionOpsDialog *sessionOpsDialog | |
| @@ -780,6 +780,7 @@ type Interactive struct { | |||||
| 780 | logoutDialog *logoutDialog | 780 | logoutDialog *logoutDialog | ||
| 781 | telegramDialog *telegramDialog | 781 | telegramDialog *telegramDialog | ||
| 782 | settingsDialog *settingsDialog | 782 | settingsDialog *settingsDialog | ||
| 783 | + | floatingPane tui.FloatingPane | |||
| 783 | quickModelAssign int | 784 | quickModelAssign int | ||
| 784 | telegramBridge *telegram.Bridge | 785 | telegramBridge *telegram.Bridge | ||
| 785 | sessionOpsDialog *sessionOpsDialog | 786 | sessionOpsDialog *sessionOpsDialog | ||
M packages/agent/modes/interactive_rendering.go +111 -81
| @@ -492,76 +492,85 @@ 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 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth) | |
| 541 | 544 | case i.changelogDialog.Active(): | |
| 542 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) | |
| 545 | + | dialogID = "changelog" | |
| 546 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, dialogWidth) | |
| 543 | 547 | case i.logoutDialog.Active(): | |
| 544 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) | |
| 548 | + | dialogID = "logout" | |
| 549 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, dialogWidth) | |
| 545 | 550 | case i.telegramDialog.Active(): | |
| 546 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) | |
| 551 | + | dialogID = "telegram" | |
| 552 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, dialogWidth) | |
| 547 | 553 | case i.settingsDialog.Active(): | |
| 548 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) | |
| 554 | + | dialogID = "settings" | |
| 555 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, dialogWidth) | |
| 549 | 556 | case i.sessionOpsDialog.Active(): | |
| 550 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) | |
| 557 | + | dialogID = "session-ops" | |
| 558 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, dialogWidth) | |
| 551 | 559 | 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) | |
| 560 | + | dialogID = "session-tree" | |
| 561 | + | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) | |
| 562 | + | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth) | |
| 561 | 563 | } | |
| 562 | 564 | if len(dialog) > 0 { | |
| 563 | 565 | dialog = padDialogFrame(dialog) | |
| 564 | 566 | } | |
| 567 | + | dialogFocusRow := -1 | |
| 568 | + | for row, line := range dialog { | |
| 569 | + | if strings.Contains(line, i.cfg.Theme.SelectionStyle()) { | |
| 570 | + | dialogFocusRow = row | |
| 571 | + | break | |
| 572 | + | } | |
| 573 | + | } | |
| 565 | 574 | modalBackdrop := len(dialog) > 0 | |
| 566 | 575 | ||
| 567 | 576 | // Slash-command autocomplete: popup above the status line, only | |
| @@ -695,10 +704,12 @@ func (i *Interactive) redraw() { | |||
| 695 | 704 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | |
| 696 | 705 | } | |
| 697 | 706 | edLines, curR, curC := i.ed.Render(mainCols) | |
| 698 | - | dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |
| 707 | + | // Floating independent views no longer replace the main bottom band: the | |
| 708 | + | // underlying chat, status, and editor keep updating behind the pane. | |
| 709 | + | residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |
| 699 | 710 | var allResidentSubagentLines []string | |
| 700 | 711 | residentSubagentHidden := 0 | |
| 701 | - | if !dashboardActive && i.cfg.ResidentManager != nil { | |
| 712 | + | if !residentViewActive && i.cfg.ResidentManager != nil { | |
| 702 | 713 | const residentIndicatorSnapshotLimit = 8 | |
| 703 | 714 | snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit) | |
| 704 | 715 | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | |
| @@ -752,17 +763,8 @@ func (i *Interactive) redraw() { | |||
| 752 | 763 | // blanks so spacing stays consistent whether or not a dialog or | |
| 753 | 764 | // popup is showing. | |
| 754 | 765 | 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) | |
| 766 | + | bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) | |
| 756 | 767 | 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 | 768 | bottom = append(bottom, suggest...) | |
| 767 | 769 | bottom = append(bottom, queue...) | |
| 768 | 770 | lineInput := inputStyle == tui.InputStyleLines | |
| @@ -814,7 +816,7 @@ func (i *Interactive) redraw() { | |||
| 814 | 816 | var residentSubagentLines []string | |
| 815 | 817 | var bottom []string | |
| 816 | 818 | var inputStartRow int | |
| 817 | - | if !dashboardActive && len(allResidentSubagentLines) > 0 { | |
| 819 | + | if len(allResidentSubagentLines) > 0 { | |
| 818 | 820 | for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- { | |
| 819 | 821 | candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols) | |
| 820 | 822 | candidateBottom, _ := composeBottom(candidate) | |
| @@ -969,39 +971,24 @@ func (i *Interactive) redraw() { | |||
| 969 | 971 | } | |
| 970 | 972 | } | |
| 971 | 973 | ||
| 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 | 974 | // Default: the real terminal cursor sits on the main editor's | |
| 984 | 975 | // input position. In main-screen log mode cursor rows are relative | |
| 985 | 976 | // 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. | |
| 977 | + | // Floating content has its own coordinate space, so dialog cursor rows | |
| 978 | + | // remain relative to the content passed to FloatingPane.Compose. | |
| 989 | 979 | dialogLead := 0 | |
| 990 | - | if len(dialog) > 0 { | |
| 991 | - | dialogLead = 1 | |
| 992 | - | } | |
| 993 | 980 | cursorRow := inputStartRow + inputCursorOffset + curR | |
| 994 | 981 | cursorCol := curC + inputCursorColOffset | |
| 995 | 982 | cursorInDialog := false | |
| 996 | 983 | if i.btwDialog.Active() { | |
| 997 | - | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | |
| 984 | + | if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 { | |
| 998 | 985 | cursorRow = dialogLead + r | |
| 999 | 986 | cursorCol = c | |
| 1000 | 987 | cursorInDialog = true | |
| 1001 | 988 | } | |
| 1002 | 989 | } | |
| 1003 | 990 | if i.dialog.Active() { | |
| 1004 | - | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | |
| 991 | + | if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 { | |
| 1005 | 992 | cursorRow = dialogLead + r | |
| 1006 | 993 | cursorCol = c | |
| 1007 | 994 | cursorInDialog = true | |
| @@ -1035,6 +1022,10 @@ func (i *Interactive) redraw() { | |||
| 1035 | 1022 | cursorRow = -1 | |
| 1036 | 1023 | cursorCol = 0 | |
| 1037 | 1024 | } | |
| 1025 | + | dialogCursorRow, dialogCursorCol := dialogFocusRow, 0 | |
| 1026 | + | if cursorInDialog { | |
| 1027 | + | dialogCursorRow, dialogCursorCol = cursorRow, cursorCol | |
| 1028 | + | } | |
| 1038 | 1029 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |
| 1039 | 1030 | if i.renderRevision.Load() != renderRevision { | |
| 1040 | 1031 | i.mu.Unlock() | |
| @@ -1044,13 +1035,17 @@ func (i *Interactive) redraw() { | |||
| 1044 | 1035 | // State assembly above is synchronized; terminal output is owned by | |
| 1045 | 1036 | // this renderer goroutine and must not hold the global state mutex. | |
| 1046 | 1037 | 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) | |
| 1038 | + | if modalBackdrop { | |
| 1039 | + | background := floatingBackgroundFrame(visibleChat, bottom, rows) | |
| 1040 | + | if rightBarActive { | |
| 1041 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 1042 | + | background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth) | |
| 1053 | 1043 | } | |
| 1044 | + | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | |
| 1045 | + | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | |
| 1046 | + | } else if rightBarActive { | |
| 1047 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 1048 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 1054 | 1049 | } else { | |
| 1055 | 1050 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 1056 | 1051 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| @@ -1063,6 +1058,41 @@ func (i *Interactive) redraw() { | |||
| 1063 | 1058 | } | |
| 1064 | 1059 | i.mu.Unlock() | |
| 1065 | 1060 | } | |
| 1061 | + | ||
| 1062 | + | // floatingBackgroundFrame preserves the regular viewport layout while an | |
| 1063 | + | // independent view is open. It is a fixed terminal frame only for the overlay | |
| 1064 | + | // lifetime; normal rendering immediately returns to scrollback-oriented flow | |
| 1065 | + | // when the pane closes. | |
| 1066 | + | func floatingBackgroundFrame(chat, bottom []string, rows int) []string { | |
| 1067 | + | if rows < 1 { | |
| 1068 | + | return nil | |
| 1069 | + | } | |
| 1070 | + | // Match DrawLog's logical order and renderer-owned bottom margin, then | |
| 1071 | + | // retain exactly the terminal viewport. In particular, an editor/status | |
| 1072 | + | // band taller than the terminal must show its newest rows, not its oldest. | |
| 1073 | + | lines := make([]string, 0, len(chat)+len(bottom)+1) | |
| 1074 | + | lines = append(lines, chat...) | |
| 1075 | + | lines = append(lines, bottom...) | |
| 1076 | + | lines = append(lines, "") | |
| 1077 | + | if len(lines) > rows { | |
| 1078 | + | lines = lines[len(lines)-rows:] | |
| 1079 | + | } | |
| 1080 | + | frame := make([]string, rows) | |
| 1081 | + | copy(frame[rows-len(lines):], lines) | |
| 1082 | + | return frame | |
| 1083 | + | } | |
| 1084 | + | ||
| 1085 | + | func floatingRightBarFrame(theme tui.Theme, main, rightBar []string, mainWidth, rightBarWidth int) []string { | |
| 1086 | + | for row := range main { | |
| 1087 | + | barLine := "" | |
| 1088 | + | if row < len(rightBar) { | |
| 1089 | + | barLine = rightBar[row] | |
| 1090 | + | } | |
| 1091 | + | main[row] = tui.JoinRightBar(theme, main[row], barLine, mainWidth, rightBarWidth) | |
| 1092 | + | } | |
| 1093 | + | return main | |
| 1094 | + | } | |
| 1095 | + | ||
| 1066 | 1096 | func hasImageEscape(line string) bool { | |
| 1067 | 1097 | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | |
| 1068 | 1098 | } | |
| @@ -492,76 +492,85 @@ 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 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 541 | case i.changelogDialog.Active(): | 544 | case i.changelogDialog.Active(): | ||
| 542 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) | 545 | + | dialogID = "changelog" |
| 546 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 543 | case i.logoutDialog.Active(): | 547 | case i.logoutDialog.Active(): | ||
| 544 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) | 548 | + | dialogID = "logout" |
| 549 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 545 | case i.telegramDialog.Active(): | 550 | case i.telegramDialog.Active(): | ||
| 546 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) | 551 | + | dialogID = "telegram" |
| 552 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 547 | case i.settingsDialog.Active(): | 553 | case i.settingsDialog.Active(): | ||
| 548 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) | 554 | + | dialogID = "settings" |
| 555 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 549 | case i.sessionOpsDialog.Active(): | 556 | case i.sessionOpsDialog.Active(): | ||
| 550 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) | 557 | + | dialogID = "session-ops" |
| 558 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, dialogWidth) | |||
| 551 | case i.sessionTreeDialog.Active(): | 559 | case i.sessionTreeDialog.Active(): | ||
| 552 | - | // Reserve rows for the editor, status, and tree chrome while using | 560 | + | dialogID = "session-tree" |
| 553 | - | // the same budget for rendering and PageUp/PageDown movement. | 561 | + | i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5) |
| 554 | - | _, rows := i.cfg.Terminal.Size() | 562 | + | 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 | } | 563 | } | ||
| 562 | if len(dialog) > 0 { | 564 | if len(dialog) > 0 { | ||
| 563 | dialog = padDialogFrame(dialog) | 565 | dialog = padDialogFrame(dialog) | ||
| 564 | } | 566 | } | ||
| 567 | + | dialogFocusRow := -1 | |||
| 568 | + | for row, line := range dialog { | |||
| 569 | + | if strings.Contains(line, i.cfg.Theme.SelectionStyle()) { | |||
| 570 | + | dialogFocusRow = row | |||
| 571 | + | break | |||
| 572 | + | } | |||
| 573 | + | } | |||
| 565 | modalBackdrop := len(dialog) > 0 | 574 | modalBackdrop := len(dialog) > 0 | ||
| 566 | 575 | ||||
| 567 | // Slash-command autocomplete: popup above the status line, only | 576 | // Slash-command autocomplete: popup above the status line, only | ||
| @@ -695,10 +704,12 @@ func (i *Interactive) redraw() { | |||||
| 695 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | 704 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | ||
| 696 | } | 705 | } | ||
| 697 | edLines, curR, curC := i.ed.Render(mainCols) | 706 | edLines, curR, curC := i.ed.Render(mainCols) | ||
| 698 | - | dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | 707 | + | // Floating independent views no longer replace the main bottom band: the |
| 708 | + | // underlying chat, status, and editor keep updating behind the pane. | |||
| 709 | + | residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |||
| 699 | var allResidentSubagentLines []string | 710 | var allResidentSubagentLines []string | ||
| 700 | residentSubagentHidden := 0 | 711 | residentSubagentHidden := 0 | ||
| 701 | - | if !dashboardActive && i.cfg.ResidentManager != nil { | 712 | + | if !residentViewActive && i.cfg.ResidentManager != nil { |
| 702 | const residentIndicatorSnapshotLimit = 8 | 713 | const residentIndicatorSnapshotLimit = 8 | ||
| 703 | snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit) | 714 | snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit) | ||
| 704 | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | 715 | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | ||
| @@ -752,17 +763,8 @@ func (i *Interactive) redraw() { | |||||
| 752 | // blanks so spacing stays consistent whether or not a dialog or | 763 | // blanks so spacing stays consistent whether or not a dialog or | ||
| 753 | // popup is showing. | 764 | // popup is showing. | ||
| 754 | composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) { | 765 | 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) | 766 | + | bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9) |
| 756 | inputStartRow = -1 | 767 | 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...) | 768 | bottom = append(bottom, suggest...) | ||
| 767 | bottom = append(bottom, queue...) | 769 | bottom = append(bottom, queue...) | ||
| 768 | lineInput := inputStyle == tui.InputStyleLines | 770 | lineInput := inputStyle == tui.InputStyleLines | ||
| @@ -814,7 +816,7 @@ func (i *Interactive) redraw() { | |||||
| 814 | var residentSubagentLines []string | 816 | var residentSubagentLines []string | ||
| 815 | var bottom []string | 817 | var bottom []string | ||
| 816 | var inputStartRow int | 818 | var inputStartRow int | ||
| 817 | - | if !dashboardActive && len(allResidentSubagentLines) > 0 { | 819 | + | if len(allResidentSubagentLines) > 0 { |
| 818 | for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- { | 820 | for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- { | ||
| 819 | candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols) | 821 | candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols) | ||
| 820 | candidateBottom, _ := composeBottom(candidate) | 822 | candidateBottom, _ := composeBottom(candidate) | ||
| @@ -969,39 +971,24 @@ func (i *Interactive) redraw() { | |||||
| 969 | } | 971 | } | ||
| 970 | } | 972 | } | ||
| 971 | 973 | ||||
| 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 | 974 | // Default: the real terminal cursor sits on the main editor's | ||
| 984 | // input position. In main-screen log mode cursor rows are relative | 975 | // input position. In main-screen log mode cursor rows are relative | ||
| 985 | // to the fixed bottom band, not the chat transcript. | 976 | // to the fixed bottom band, not the chat transcript. | ||
| 986 | - | // dialogLead is 1 when the bottom region prepends a blank above | 977 | + | // Floating content has its own coordinate space, so dialog cursor rows |
| 987 | - | // the dialog block (whenever a dialog is showing) so popup-side | 978 | + | // remain relative to the content passed to FloatingPane.Compose. |
| 988 | - | // cursor positions still land in the right cell. | |||
| 989 | dialogLead := 0 | 979 | dialogLead := 0 | ||
| 990 | - | if len(dialog) > 0 { | |||
| 991 | - | dialogLead = 1 | |||
| 992 | - | } | |||
| 993 | cursorRow := inputStartRow + inputCursorOffset + curR | 980 | cursorRow := inputStartRow + inputCursorOffset + curR | ||
| 994 | cursorCol := curC + inputCursorColOffset | 981 | cursorCol := curC + inputCursorColOffset | ||
| 995 | cursorInDialog := false | 982 | cursorInDialog := false | ||
| 996 | if i.btwDialog.Active() { | 983 | if i.btwDialog.Active() { | ||
| 997 | - | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | 984 | + | if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 { |
| 998 | cursorRow = dialogLead + r | 985 | cursorRow = dialogLead + r | ||
| 999 | cursorCol = c | 986 | cursorCol = c | ||
| 1000 | cursorInDialog = true | 987 | cursorInDialog = true | ||
| 1001 | } | 988 | } | ||
| 1002 | } | 989 | } | ||
| 1003 | if i.dialog.Active() { | 990 | if i.dialog.Active() { | ||
| 1004 | - | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | 991 | + | if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 { |
| 1005 | cursorRow = dialogLead + r | 992 | cursorRow = dialogLead + r | ||
| 1006 | cursorCol = c | 993 | cursorCol = c | ||
| 1007 | cursorInDialog = true | 994 | cursorInDialog = true | ||
| @@ -1035,6 +1022,10 @@ func (i *Interactive) redraw() { | |||||
| 1035 | cursorRow = -1 | 1022 | cursorRow = -1 | ||
| 1036 | cursorCol = 0 | 1023 | cursorCol = 0 | ||
| 1037 | } | 1024 | } | ||
| 1025 | + | dialogCursorRow, dialogCursorCol := dialogFocusRow, 0 | |||
| 1026 | + | if cursorInDialog { | |||
| 1027 | + | dialogCursorRow, dialogCursorCol = cursorRow, cursorCol | |||
| 1028 | + | } | |||
| 1038 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | 1029 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | ||
| 1039 | if i.renderRevision.Load() != renderRevision { | 1030 | if i.renderRevision.Load() != renderRevision { | ||
| 1040 | i.mu.Unlock() | 1031 | i.mu.Unlock() | ||
| @@ -1044,13 +1035,17 @@ func (i *Interactive) redraw() { | |||||
| 1044 | // State assembly above is synchronized; terminal output is owned by | 1035 | // State assembly above is synchronized; terminal output is owned by | ||
| 1045 | // this renderer goroutine and must not hold the global state mutex. | 1036 | // this renderer goroutine and must not hold the global state mutex. | ||
| 1046 | i.mu.Unlock() | 1037 | i.mu.Unlock() | ||
| 1047 | - | if rightBarActive { | 1038 | + | if modalBackdrop { |
| 1048 | - | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | 1039 | + | background := floatingBackgroundFrame(visibleChat, bottom, rows) |
| 1049 | - | if modalBackdrop { | 1040 | + | if rightBarActive { |
| 1050 | - | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | 1041 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) |
| 1051 | - | } else { | 1042 | + | background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth) |
| 1052 | - | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |||
| 1053 | } | 1043 | } | ||
| 1044 | + | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | |||
| 1045 | + | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | |||
| 1046 | + | } else if rightBarActive { | |||
| 1047 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |||
| 1048 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |||
| 1054 | } else { | 1049 | } else { | ||
| 1055 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 1050 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | ||
| 1056 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 1051 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | ||
| @@ -1063,6 +1058,41 @@ func (i *Interactive) redraw() { | |||||
| 1063 | } | 1058 | } | ||
| 1064 | i.mu.Unlock() | 1059 | i.mu.Unlock() | ||
| 1065 | } | 1060 | } | ||
| 1061 | + | ||||
| 1062 | + | // floatingBackgroundFrame preserves the regular viewport layout while an | |||
| 1063 | + | // independent view is open. It is a fixed terminal frame only for the overlay | |||
| 1064 | + | // lifetime; normal rendering immediately returns to scrollback-oriented flow | |||
| 1065 | + | // when the pane closes. | |||
| 1066 | + | func floatingBackgroundFrame(chat, bottom []string, rows int) []string { | |||
| 1067 | + | if rows < 1 { | |||
| 1068 | + | return nil | |||
| 1069 | + | } | |||
| 1070 | + | // Match DrawLog's logical order and renderer-owned bottom margin, then | |||
| 1071 | + | // retain exactly the terminal viewport. In particular, an editor/status | |||
| 1072 | + | // band taller than the terminal must show its newest rows, not its oldest. | |||
| 1073 | + | lines := make([]string, 0, len(chat)+len(bottom)+1) | |||
| 1074 | + | lines = append(lines, chat...) | |||
| 1075 | + | lines = append(lines, bottom...) | |||
| 1076 | + | lines = append(lines, "") | |||
| 1077 | + | if len(lines) > rows { | |||
| 1078 | + | lines = lines[len(lines)-rows:] | |||
| 1079 | + | } | |||
| 1080 | + | frame := make([]string, rows) | |||
| 1081 | + | copy(frame[rows-len(lines):], lines) | |||
| 1082 | + | return frame | |||
| 1083 | + | } | |||
| 1084 | + | ||||
| 1085 | + | func floatingRightBarFrame(theme tui.Theme, main, rightBar []string, mainWidth, rightBarWidth int) []string { | |||
| 1086 | + | for row := range main { | |||
| 1087 | + | barLine := "" | |||
| 1088 | + | if row < len(rightBar) { | |||
| 1089 | + | barLine = rightBar[row] | |||
| 1090 | + | } | |||
| 1091 | + | main[row] = tui.JoinRightBar(theme, main[row], barLine, mainWidth, rightBarWidth) | |||
| 1092 | + | } | |||
| 1093 | + | return main | |||
| 1094 | + | } | |||
| 1095 | + | ||||
| 1066 | func hasImageEscape(line string) bool { | 1096 | func hasImageEscape(line string) bool { | ||
| 1067 | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | 1097 | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | ||
| 1068 | } | 1098 | } | ||
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 +18 -1
| @@ -7,6 +7,14 @@ import ( | |||
| 7 | 7 | "github.com/bnema/zut/packages/tui" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestFloatingBackgroundFrameKeepsNewestOversizedBottomRows(t *testing.T) { | |
| 11 | + | got := floatingBackgroundFrame([]string{"chat"}, []string{"old", "newer", "newest"}, 3) | |
| 12 | + | want := []string{"newer", "newest", ""} | |
| 13 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 14 | + | t.Fatalf("floating background = %q, want %q", got, want) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 10 | 18 | func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |
| 11 | 19 | term := &alertTestTerminal{} | |
| 12 | 20 | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | |
| @@ -23,9 +31,18 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |||
| 23 | 31 | if !strings.Contains(got, tui.Dim(input)) { | |
| 24 | 32 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | |
| 25 | 33 | } | |
| 26 | - | if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) { | |
| 34 | + | pane := tui.FloatingPaneMaxRect(80, 24) | |
| 35 | + | header := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth()) | |
| 36 | + | if !strings.Contains(got, header) { | |
| 37 | + | t.Fatalf("settings dialog was not rendered inside the floating pane: %q", got) | |
| 38 | + | } | |
| 39 | + | if strings.Contains(got, tui.Dim(header)) { | |
| 27 | 40 | t.Fatalf("settings dialog itself was dimmed: %q", got) | |
| 28 | 41 | } | |
| 42 | + | // The pane shrinks to its content and remains horizontally centered. | |
| 43 | + | if !strings.Contains(got, tui.MoveTo(9, pane.X+1)) { | |
| 44 | + | t.Fatalf("floating pane was not positioned at its computed geometry: %q", got) | |
| 45 | + | } | |
| 29 | 46 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | |
| 30 | 47 | if !strings.Contains(got, cursor) { | |
| 31 | 48 | t.Fatalf("settings dialog left the main cursor bright; missing %q in %q", cursor, got) | |
| @@ -7,6 +7,14 @@ import ( | |||||
| 7 | "github.com/bnema/zut/packages/tui" | 7 | "github.com/bnema/zut/packages/tui" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestFloatingBackgroundFrameKeepsNewestOversizedBottomRows(t *testing.T) { | |||
| 11 | + | got := floatingBackgroundFrame([]string{"chat"}, []string{"old", "newer", "newest"}, 3) | |||
| 12 | + | want := []string{"newer", "newest", ""} | |||
| 13 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |||
| 14 | + | t.Fatalf("floating background = %q, want %q", got, want) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 10 | func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | 18 | func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | ||
| 11 | term := &alertTestTerminal{} | 19 | term := &alertTestTerminal{} | ||
| 12 | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | 20 | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | ||
| @@ -23,9 +31,18 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) { | |||||
| 23 | if !strings.Contains(got, tui.Dim(input)) { | 31 | if !strings.Contains(got, tui.Dim(input)) { | ||
| 24 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | 32 | t.Fatalf("settings dialog left the main input undimmed: %q", got) | ||
| 25 | } | 33 | } | ||
| 26 | - | if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) { | 34 | + | pane := tui.FloatingPaneMaxRect(80, 24) |
| 35 | + | header := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth()) | |||
| 36 | + | if !strings.Contains(got, header) { | |||
| 37 | + | t.Fatalf("settings dialog was not rendered inside the floating pane: %q", got) | |||
| 38 | + | } | |||
| 39 | + | if strings.Contains(got, tui.Dim(header)) { | |||
| 27 | t.Fatalf("settings dialog itself was dimmed: %q", got) | 40 | t.Fatalf("settings dialog itself was dimmed: %q", got) | ||
| 28 | } | 41 | } | ||
| 42 | + | // The pane shrinks to its content and remains horizontally centered. | |||
| 43 | + | if !strings.Contains(got, tui.MoveTo(9, pane.X+1)) { | |||
| 44 | + | t.Fatalf("floating pane was not positioned at its computed geometry: %q", got) | |||
| 45 | + | } | |||
| 29 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | 46 | cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent)) | ||
| 30 | if !strings.Contains(got, cursor) { | 47 | if !strings.Contains(got, cursor) { | ||
| 31 | t.Fatalf("settings dialog left the main cursor bright; missing %q in %q", cursor, got) | 48 | t.Fatalf("settings dialog left the main cursor bright; missing %q in %q", cursor, got) | ||
A packages/tui/floating_pane.go +242 -0
| @@ -0,0 +1,242 @@ | |||
| 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. contentCursorRow is relative | |
| 105 | + | // to content and is used both to retain the pane's own viewport and to return | |
| 106 | + | // an absolute terminal cursor row. Pass -1 when the foreground has no text | |
| 107 | + | // cursor. | |
| 108 | + | func (p *FloatingPane) Compose(theme Theme, id 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, 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, 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] = theme.FGColor(theme.Muted, strings.Repeat("─", rect.Width)) | |
| 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] = theme.FGColor(theme.Muted, "╭"+strings.Repeat("─", rect.Width-2)+"╮") | |
| 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 | + | } | |
| @@ -0,0 +1,242 @@ | |||||
| 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. contentCursorRow is relative | |||
| 105 | + | // to content and is used both to retain the pane's own viewport and to return | |||
| 106 | + | // an absolute terminal cursor row. Pass -1 when the foreground has no text | |||
| 107 | + | // cursor. | |||
| 108 | + | func (p *FloatingPane) Compose(theme Theme, id 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, 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, 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] = theme.FGColor(theme.Muted, strings.Repeat("─", rect.Width)) | |||
| 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] = theme.FGColor(theme.Muted, "╭"+strings.Repeat("─", rect.Width-2)+"╮") | |||
| 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 | + | } | |||
A packages/tui/floating_pane_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 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", []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 | + | } | |
| 24 | + | if cursorRow != -1 || cursorCol != 0 { | |
| 25 | + | t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | |
| 30 | + | var pane FloatingPane | |
| 31 | + | frame, _, _ := pane.Compose(Dark, "settings", nil, []string{"settings body"}, 79, 24, -1, 0) | |
| 32 | + | ||
| 33 | + | if !frame.Rect.Drawer { | |
| 34 | + | t.Fatal("79-column terminal did not use drawer layout") | |
| 35 | + | } | |
| 36 | + | if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 { | |
| 37 | + | t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect) | |
| 38 | + | } | |
| 39 | + | if got := stripANSI(frame.Lines[0]); strings.Trim(got, "─") != "" { | |
| 40 | + | t.Fatalf("drawer top border = %q, want rule only", got) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | |
| 45 | + | var pane FloatingPane | |
| 46 | + | wide, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 100, 30, -1, 0) | |
| 47 | + | narrow, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 79, 24, -1, 0) | |
| 48 | + | ||
| 49 | + | if wide.Rect.Drawer { | |
| 50 | + | t.Fatal("wide terminal unexpectedly used a drawer") | |
| 51 | + | } | |
| 52 | + | if !narrow.Rect.Drawer || narrow.Rect.Width != 79 || narrow.Rect.Y+narrow.Rect.Height != 24 { | |
| 53 | + | t.Fatalf("resize did not recompute a bottom drawer: %+v", narrow.Rect) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) { | |
| 58 | + | var pane FloatingPane | |
| 59 | + | frame, _, col := pane.Compose(Dark, "search", nil, []string{"query"}, 100, 20, 0, 1000) | |
| 60 | + | if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want { | |
| 61 | + | t.Fatalf("cursor col = %d, want content edge %d", col, want) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | tiny, row, tinyCol := pane.Compose(Dark, "search", nil, []string{"x"}, 2, 2, 0, 10) | |
| 65 | + | if !tiny.Rect.Borderless { | |
| 66 | + | t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect) | |
| 67 | + | } | |
| 68 | + | if row != 0 || tinyCol != 1 { | |
| 69 | + | t.Fatalf("tiny cursor = (%d, %d), want (0, 1)", row, tinyCol) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.T) { | |
| 74 | + | content := make([]string, 30) | |
| 75 | + | for row := range content { | |
| 76 | + | content[row] = "row " + itoa(row) | |
| 77 | + | } | |
| 78 | + | var pane FloatingPane | |
| 79 | + | frame, cursorRow, _ := pane.Compose(Dark, "settings", nil, content, 100, 12, 25, 0) | |
| 80 | + | if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 { | |
| 81 | + | t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect) | |
| 82 | + | } | |
| 83 | + | if !strings.Contains(stripANSI(strings.Join(frame.Lines, "\n")), "row 25") { | |
| 84 | + | t.Fatal("focused row was not retained in the pane viewport") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | other, _, _ := pane.Compose(Dark, "subagents", nil, content, 100, 12, -1, 0) | |
| 88 | + | if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") { | |
| 89 | + | t.Fatal("new view inherited the previous view's scroll position") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testing.T) { | |
| 94 | + | var out strings.Builder | |
| 95 | + | r := NewRenderer(&out) | |
| 96 | + | r.Resize(100, 20) | |
| 97 | + | var pane FloatingPane | |
| 98 | + | frame, _, _ := pane.Compose(Dark, "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0) | |
| 99 | + | r.DrawFloating(frame, -1, 0) | |
| 100 | + | got := out.String() | |
| 101 | + | if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") { | |
| 102 | + | t.Fatalf("floating render omitted a layer: %q", got) | |
| 103 | + | } | |
| 104 | + | if !strings.Contains(got, MoveTo(frame.Rect.Y+1, frame.Rect.X+1)) { | |
| 105 | + | t.Fatalf("floating pane was not painted at its geometry: %q", got) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | out.Reset() | |
| 109 | + | r.Draw(frame.Background, -1, 0) | |
| 110 | + | if !strings.Contains(out.String(), MoveTo(1, 1)) { | |
| 111 | + | t.Fatalf("normal frame did not repaint after a floating pane: %q", out.String()) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestRendererDrawFloatingPreservesThemeBackgroundAndImageInvalidation(t *testing.T) { | |
| 116 | + | var out strings.Builder | |
| 117 | + | r := NewRenderer(&out) | |
| 118 | + | background := Color256(237) | |
| 119 | + | th := Dark | |
| 120 | + | th.Background = &background | |
| 121 | + | r.SetTheme(th) | |
| 122 | + | r.Resize(20, 4) | |
| 123 | + | ||
| 124 | + | pane := FloatingPaneFrame{ | |
| 125 | + | Background: make([]string, 4), | |
| 126 | + | Lines: []string{"\x1b_Ga=T,f=100;image\x1b\\"}, | |
| 127 | + | Rect: FloatingPaneRect{X: 2, Y: 1, Width: 10, Height: 1, Borderless: true}, | |
| 128 | + | } | |
| 129 | + | r.DrawFloating(pane, -1, 0) | |
| 130 | + | if !strings.Contains(out.String(), th.BackgroundStyle()) { | |
| 131 | + | t.Fatalf("floating pane omitted theme background: %q", out.String()) | |
| 132 | + | } | |
| 133 | + | if !r.prevHadImage || !r.prevHadKittyImage { | |
| 134 | + | t.Fatalf("floating image state = image:%t kitty:%t, want both true", r.prevHadImage, r.prevHadKittyImage) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | out.Reset() | |
| 138 | + | r.Draw([]string{""}, -1, 0) | |
| 139 | + | if !strings.Contains(out.String(), SeqDeleteKittyImages) { | |
| 140 | + | t.Fatalf("stale kitty image was not deleted: %q", out.String()) | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 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", []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 | + | } | |||
| 24 | + | if cursorRow != -1 || cursorCol != 0 { | |||
| 25 | + | t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) { | |||
| 30 | + | var pane FloatingPane | |||
| 31 | + | frame, _, _ := pane.Compose(Dark, "settings", nil, []string{"settings body"}, 79, 24, -1, 0) | |||
| 32 | + | ||||
| 33 | + | if !frame.Rect.Drawer { | |||
| 34 | + | t.Fatal("79-column terminal did not use drawer layout") | |||
| 35 | + | } | |||
| 36 | + | if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 { | |||
| 37 | + | t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect) | |||
| 38 | + | } | |||
| 39 | + | if got := stripANSI(frame.Lines[0]); strings.Trim(got, "─") != "" { | |||
| 40 | + | t.Fatalf("drawer top border = %q, want rule only", got) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) { | |||
| 45 | + | var pane FloatingPane | |||
| 46 | + | wide, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 100, 30, -1, 0) | |||
| 47 | + | narrow, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 79, 24, -1, 0) | |||
| 48 | + | ||||
| 49 | + | if wide.Rect.Drawer { | |||
| 50 | + | t.Fatal("wide terminal unexpectedly used a drawer") | |||
| 51 | + | } | |||
| 52 | + | if !narrow.Rect.Drawer || narrow.Rect.Width != 79 || narrow.Rect.Y+narrow.Rect.Height != 24 { | |||
| 53 | + | t.Fatalf("resize did not recompute a bottom drawer: %+v", narrow.Rect) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) { | |||
| 58 | + | var pane FloatingPane | |||
| 59 | + | frame, _, col := pane.Compose(Dark, "search", nil, []string{"query"}, 100, 20, 0, 1000) | |||
| 60 | + | if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want { | |||
| 61 | + | t.Fatalf("cursor col = %d, want content edge %d", col, want) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | tiny, row, tinyCol := pane.Compose(Dark, "search", nil, []string{"x"}, 2, 2, 0, 10) | |||
| 65 | + | if !tiny.Rect.Borderless { | |||
| 66 | + | t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect) | |||
| 67 | + | } | |||
| 68 | + | if row != 0 || tinyCol != 1 { | |||
| 69 | + | t.Fatalf("tiny cursor = (%d, %d), want (0, 1)", row, tinyCol) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.T) { | |||
| 74 | + | content := make([]string, 30) | |||
| 75 | + | for row := range content { | |||
| 76 | + | content[row] = "row " + itoa(row) | |||
| 77 | + | } | |||
| 78 | + | var pane FloatingPane | |||
| 79 | + | frame, cursorRow, _ := pane.Compose(Dark, "settings", nil, content, 100, 12, 25, 0) | |||
| 80 | + | if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 { | |||
| 81 | + | t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect) | |||
| 82 | + | } | |||
| 83 | + | if !strings.Contains(stripANSI(strings.Join(frame.Lines, "\n")), "row 25") { | |||
| 84 | + | t.Fatal("focused row was not retained in the pane viewport") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | other, _, _ := pane.Compose(Dark, "subagents", nil, content, 100, 12, -1, 0) | |||
| 88 | + | if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") { | |||
| 89 | + | t.Fatal("new view inherited the previous view's scroll position") | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testing.T) { | |||
| 94 | + | var out strings.Builder | |||
| 95 | + | r := NewRenderer(&out) | |||
| 96 | + | r.Resize(100, 20) | |||
| 97 | + | var pane FloatingPane | |||
| 98 | + | frame, _, _ := pane.Compose(Dark, "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0) | |||
| 99 | + | r.DrawFloating(frame, -1, 0) | |||
| 100 | + | got := out.String() | |||
| 101 | + | if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") { | |||
| 102 | + | t.Fatalf("floating render omitted a layer: %q", got) | |||
| 103 | + | } | |||
| 104 | + | if !strings.Contains(got, MoveTo(frame.Rect.Y+1, frame.Rect.X+1)) { | |||
| 105 | + | t.Fatalf("floating pane was not painted at its geometry: %q", got) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | out.Reset() | |||
| 109 | + | r.Draw(frame.Background, -1, 0) | |||
| 110 | + | if !strings.Contains(out.String(), MoveTo(1, 1)) { | |||
| 111 | + | t.Fatalf("normal frame did not repaint after a floating pane: %q", out.String()) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestRendererDrawFloatingPreservesThemeBackgroundAndImageInvalidation(t *testing.T) { | |||
| 116 | + | var out strings.Builder | |||
| 117 | + | r := NewRenderer(&out) | |||
| 118 | + | background := Color256(237) | |||
| 119 | + | th := Dark | |||
| 120 | + | th.Background = &background | |||
| 121 | + | r.SetTheme(th) | |||
| 122 | + | r.Resize(20, 4) | |||
| 123 | + | ||||
| 124 | + | pane := FloatingPaneFrame{ | |||
| 125 | + | Background: make([]string, 4), | |||
| 126 | + | Lines: []string{"\x1b_Ga=T,f=100;image\x1b\\"}, | |||
| 127 | + | Rect: FloatingPaneRect{X: 2, Y: 1, Width: 10, Height: 1, Borderless: true}, | |||
| 128 | + | } | |||
| 129 | + | r.DrawFloating(pane, -1, 0) | |||
| 130 | + | if !strings.Contains(out.String(), th.BackgroundStyle()) { | |||
| 131 | + | t.Fatalf("floating pane omitted theme background: %q", out.String()) | |||
| 132 | + | } | |||
| 133 | + | if !r.prevHadImage || !r.prevHadKittyImage { | |||
| 134 | + | t.Fatalf("floating image state = image:%t kitty:%t, want both true", r.prevHadImage, r.prevHadKittyImage) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | out.Reset() | |||
| 138 | + | r.Draw([]string{""}, -1, 0) | |||
| 139 | + | if !strings.Contains(out.String(), SeqDeleteKittyImages) { | |||
| 140 | + | t.Fatalf("stale kitty image was not deleted: %q", out.String()) | |||
| 141 | + | } | |||
| 142 | + | } | |||
M packages/tui/render.go +58 -8
| @@ -27,9 +27,10 @@ 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 | |
| 33 | 34 | ||
| 34 | 35 | // Main-screen flow renderer state. logLines is the full logical | |
| 35 | 36 | // buffer (chat + live bottom band) from the previous DrawLog call. | |
| @@ -468,11 +469,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 468 | 469 | // its scrollbar to the top of the viewport on every image or | |
| 469 | 470 | // selection-highlight frame. | |
| 470 | 471 | 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\\") | |
| 472 | + | if curHasKittyImage || r.prevHadKittyImage { | |
| 473 | + | // Delete previously placed kitty images once per frame, before | |
| 474 | + | // rewriting all rows. This also removes a former foreground image | |
| 475 | + | // when the replacement frame contains no Kitty image escape. | |
| 476 | + | w.WriteString(SeqDeleteKittyImages) | |
| 476 | 477 | } | |
| 477 | 478 | } | |
| 478 | 479 | ||
| @@ -523,10 +524,59 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 523 | 524 | ||
| 524 | 525 | r.prev = frame | |
| 525 | 526 | r.prevHadImage = curHasImage | |
| 527 | + | r.prevHadKittyImage = curHasKittyImage | |
| 526 | 528 | r.cursorRow = cursorRow | |
| 527 | 529 | r.cursorCol = cursorCol | |
| 528 | 530 | } | |
| 529 | 531 | ||
| 532 | + | // DrawFloating paints a complete, dimmed live background and then an opaque | |
| 533 | + | // floating pane at absolute coordinates. The background is deliberately | |
| 534 | + | // invalidated on every call: the pane is not represented in Draw's frame cache, | |
| 535 | + | // so repainting it first prevents stale foreground cells when either layer | |
| 536 | + | // changes or the terminal is resized. | |
| 537 | + | func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int) { | |
| 538 | + | if r.cols == 0 || r.rows == 0 { | |
| 539 | + | return | |
| 540 | + | } | |
| 541 | + | r.Invalidate() | |
| 542 | + | r.Draw(pane.Background, -1, 0) | |
| 543 | + | ||
| 544 | + | paneHasImage := false | |
| 545 | + | paneHasKittyImage := false | |
| 546 | + | for _, line := range pane.Lines { | |
| 547 | + | if containsImageEscape(line) { | |
| 548 | + | paneHasImage = true | |
| 549 | + | if strings.Contains(line, "\x1b_G") { | |
| 550 | + | paneHasKittyImage = true | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | var w strings.Builder | |
| 556 | + | w.WriteString(SeqSynchronizedOn) | |
| 557 | + | w.WriteString(SeqHideCursor) | |
| 558 | + | for row, line := range pane.Lines { | |
| 559 | + | y := pane.Rect.Y + row | |
| 560 | + | if y < 0 || y >= r.rows { | |
| 561 | + | continue | |
| 562 | + | } | |
| 563 | + | w.WriteString(MoveTo(y+1, pane.Rect.X+1)) | |
| 564 | + | w.WriteString("\x1b[0m") | |
| 565 | + | w.WriteString(paintBackgroundRow(truncateToWidth(line, pane.Rect.Width), pane.Rect.Width, r.backgroundStyle)) | |
| 566 | + | } | |
| 567 | + | if cursorRow >= 0 && cursorRow < r.rows && cursorCol >= 0 && cursorCol < r.cols { | |
| 568 | + | w.WriteString(MoveTo(cursorRow+1, cursorCol+1)) | |
| 569 | + | w.WriteString(SeqShowCursor) | |
| 570 | + | } | |
| 571 | + | w.WriteString(SeqSynchronizedOff) | |
| 572 | + | _, _ = io.WriteString(r.out, w.String()) | |
| 573 | + | r.prevHadImage = r.prevHadImage || paneHasImage | |
| 574 | + | r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage | |
| 575 | + | // pane.Lines are painted outside Draw's frame cache, so the next normal | |
| 576 | + | // frame must be fully repainted to remove the foreground cells. | |
| 577 | + | r.Invalidate() | |
| 578 | + | } | |
| 579 | + | ||
| 530 | 580 | // DrawLog renders zut in the terminal's main screen as normal terminal | |
| 531 | 581 | // flow rather than a fixed full-screen frame. Chat lines are emitted once | |
| 532 | 582 | // into the host terminal scrollback; the current bottom block (dialogs, | |
| @@ -27,9 +27,10 @@ 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 | |||
| 33 | 34 | ||||
| 34 | // Main-screen flow renderer state. logLines is the full logical | 35 | // Main-screen flow renderer state. logLines is the full logical | ||
| 35 | // buffer (chat + live bottom band) from the previous DrawLog call. | 36 | // buffer (chat + live bottom band) from the previous DrawLog call. | ||
| @@ -468,11 +469,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 468 | // its scrollbar to the top of the viewport on every image or | 469 | // its scrollbar to the top of the viewport on every image or | ||
| 469 | // selection-highlight frame. | 470 | // selection-highlight frame. | ||
| 470 | w.WriteString(SeqClearScreenNoHome) | 471 | w.WriteString(SeqClearScreenNoHome) | ||
| 471 | - | if curHasKittyImage { | 472 | + | if curHasKittyImage || r.prevHadKittyImage { |
| 472 | - | // Delete previously placed kitty images once per frame, | 473 | + | // Delete previously placed kitty images once per frame, before |
| 473 | - | // before rewriting all rows. Doing this inside each image | 474 | + | // rewriting all rows. This also removes a former foreground image |
| 474 | - | // escape makes only the last image in the frame survive. | 475 | + | // when the replacement frame contains no Kitty image escape. |
| 475 | - | w.WriteString("\x1b_Ga=d\x1b\\") | 476 | + | w.WriteString(SeqDeleteKittyImages) |
| 476 | } | 477 | } | ||
| 477 | } | 478 | } | ||
| 478 | 479 | ||||
| @@ -523,10 +524,59 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 523 | 524 | ||||
| 524 | r.prev = frame | 525 | r.prev = frame | ||
| 525 | r.prevHadImage = curHasImage | 526 | r.prevHadImage = curHasImage | ||
| 527 | + | r.prevHadKittyImage = curHasKittyImage | |||
| 526 | r.cursorRow = cursorRow | 528 | r.cursorRow = cursorRow | ||
| 527 | r.cursorCol = cursorCol | 529 | r.cursorCol = cursorCol | ||
| 528 | } | 530 | } | ||
| 529 | 531 | ||||
| 532 | + | // DrawFloating paints a complete, dimmed live background and then an opaque | |||
| 533 | + | // floating pane at absolute coordinates. The background is deliberately | |||
| 534 | + | // invalidated on every call: the pane is not represented in Draw's frame cache, | |||
| 535 | + | // so repainting it first prevents stale foreground cells when either layer | |||
| 536 | + | // changes or the terminal is resized. | |||
| 537 | + | func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int) { | |||
| 538 | + | if r.cols == 0 || r.rows == 0 { | |||
| 539 | + | return | |||
| 540 | + | } | |||
| 541 | + | r.Invalidate() | |||
| 542 | + | r.Draw(pane.Background, -1, 0) | |||
| 543 | + | ||||
| 544 | + | paneHasImage := false | |||
| 545 | + | paneHasKittyImage := false | |||
| 546 | + | for _, line := range pane.Lines { | |||
| 547 | + | if containsImageEscape(line) { | |||
| 548 | + | paneHasImage = true | |||
| 549 | + | if strings.Contains(line, "\x1b_G") { | |||
| 550 | + | paneHasKittyImage = true | |||
| 551 | + | } | |||
| 552 | + | } | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | var w strings.Builder | |||
| 556 | + | w.WriteString(SeqSynchronizedOn) | |||
| 557 | + | w.WriteString(SeqHideCursor) | |||
| 558 | + | for row, line := range pane.Lines { | |||
| 559 | + | y := pane.Rect.Y + row | |||
| 560 | + | if y < 0 || y >= r.rows { | |||
| 561 | + | continue | |||
| 562 | + | } | |||
| 563 | + | w.WriteString(MoveTo(y+1, pane.Rect.X+1)) | |||
| 564 | + | w.WriteString("\x1b[0m") | |||
| 565 | + | w.WriteString(paintBackgroundRow(truncateToWidth(line, pane.Rect.Width), pane.Rect.Width, r.backgroundStyle)) | |||
| 566 | + | } | |||
| 567 | + | if cursorRow >= 0 && cursorRow < r.rows && cursorCol >= 0 && cursorCol < r.cols { | |||
| 568 | + | w.WriteString(MoveTo(cursorRow+1, cursorCol+1)) | |||
| 569 | + | w.WriteString(SeqShowCursor) | |||
| 570 | + | } | |||
| 571 | + | w.WriteString(SeqSynchronizedOff) | |||
| 572 | + | _, _ = io.WriteString(r.out, w.String()) | |||
| 573 | + | r.prevHadImage = r.prevHadImage || paneHasImage | |||
| 574 | + | r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage | |||
| 575 | + | // pane.Lines are painted outside Draw's frame cache, so the next normal | |||
| 576 | + | // frame must be fully repainted to remove the foreground cells. | |||
| 577 | + | r.Invalidate() | |||
| 578 | + | } | |||
| 579 | + | ||||
| 530 | // DrawLog renders zut in the terminal's main screen as normal terminal | 580 | // 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 | 581 | // flow rather than a fixed full-screen frame. Chat lines are emitted once | ||
| 532 | // into the host terminal scrollback; the current bottom block (dialogs, | 582 | // into the host terminal scrollback; the current bottom block (dialogs, | ||