detached 9e66d9a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
260260
261261 ## Modes
262262
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.
264264 - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.
265265 - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr.
266266 - **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 ## Modes261 ## 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)
187187 }
188188 d.mu.Unlock()
189189
190- rows := d.Render(tui.Theme{}, 80)
190+ const paneWidth = 62
191+ rows := d.Render(tui.Theme{}, paneWidth)
191192 rendered := strings.Join(rows, "\n")
192193 if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") {
193194 t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered)
@@ -202,7 +203,7 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
202203 if toolRow < 1 || rows[toolRow-1] != "" {
203204 t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered)
204205 }
205- if cursorRow, _ := d.CursorPos(80); cursorRow != len(rows)-3 {
206+ if cursorRow, _ := d.CursorPos(paneWidth); cursorRow != len(rows)-3 {
206207 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
207208 }
208209 }
@@ -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 {
780780 logoutDialog *logoutDialog
781781 telegramDialog *telegramDialog
782782 settingsDialog *settingsDialog
783+ floatingPane tui.FloatingPane
783784 quickModelAssign int
784785 telegramBridge *telegram.Bridge
785786 sessionOpsDialog *sessionOpsDialog
@@ -780,6 +780,7 @@ type Interactive struct {
780 logoutDialog *logoutDialog780 logoutDialog *logoutDialog
781 telegramDialog *telegramDialog781 telegramDialog *telegramDialog
782 settingsDialog *settingsDialog782 settingsDialog *settingsDialog
783+ floatingPane tui.FloatingPane
783 quickModelAssign int784 quickModelAssign int
784 telegramBridge *telegram.Bridge785 telegramBridge *telegram.Bridge
785 sessionOpsDialog *sessionOpsDialog786 sessionOpsDialog *sessionOpsDialog
M packages/agent/modes/interactive_rendering.go +111 -81
@@ -492,76 +492,85 @@ func (i *Interactive) redraw() {
492492 return
493493 }
494494
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()
496500 var dialog []string
501+ var dialogID string
497502 switch {
498503 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)
500506 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)
502509 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)
504512 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)
506515 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)
518519 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())
521522 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())
524525 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)
526528 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)
528531 case i.confirmDialog.Active():
532+ dialogID = "confirmation"
529533 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)
534535 } else {
535- dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
536+ dialog = i.confirmDialog.Render(i.cfg.Theme, dialogWidth)
536537 }
537538 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)
539541 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)
541544 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)
543547 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)
545550 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)
547553 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)
549556 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)
551559 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)
561563 }
562564 if len(dialog) > 0 {
563565 dialog = padDialogFrame(dialog)
564566 }
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+ }
565574 modalBackdrop := len(dialog) > 0
566575
567576 // Slash-command autocomplete: popup above the status line, only
@@ -695,10 +704,12 @@ func (i *Interactive) redraw() {
695704 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
696705 }
697706 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
699710 var allResidentSubagentLines []string
700711 residentSubagentHidden := 0
701- if !dashboardActive && i.cfg.ResidentManager != nil {
712+ if !residentViewActive && i.cfg.ResidentManager != nil {
702713 const residentIndicatorSnapshotLimit = 8
703714 snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
704715 allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
@@ -752,17 +763,8 @@ func (i *Interactive) redraw() {
752763 // blanks so spacing stays consistent whether or not a dialog or
753764 // popup is showing.
754765 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)
756767 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-
766768 bottom = append(bottom, suggest...)
767769 bottom = append(bottom, queue...)
768770 lineInput := inputStyle == tui.InputStyleLines
@@ -814,7 +816,7 @@ func (i *Interactive) redraw() {
814816 var residentSubagentLines []string
815817 var bottom []string
816818 var inputStartRow int
817- if !dashboardActive && len(allResidentSubagentLines) > 0 {
819+ if len(allResidentSubagentLines) > 0 {
818820 for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
819821 candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
820822 candidateBottom, _ := composeBottom(candidate)
@@ -969,39 +971,24 @@ func (i *Interactive) redraw() {
969971 }
970972 }
971973
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-
983974 // Default: the real terminal cursor sits on the main editor's
984975 // input position. In main-screen log mode cursor rows are relative
985976 // 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.
989979 dialogLead := 0
990- if len(dialog) > 0 {
991- dialogLead = 1
992- }
993980 cursorRow := inputStartRow + inputCursorOffset + curR
994981 cursorCol := curC + inputCursorColOffset
995982 cursorInDialog := false
996983 if i.btwDialog.Active() {
997- if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
984+ if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 {
998985 cursorRow = dialogLead + r
999986 cursorCol = c
1000987 cursorInDialog = true
1001988 }
1002989 }
1003990 if i.dialog.Active() {
1004- if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
991+ if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 {
1005992 cursorRow = dialogLead + r
1006993 cursorCol = c
1007994 cursorInDialog = true
@@ -1035,6 +1022,10 @@ func (i *Interactive) redraw() {
10351022 cursorRow = -1
10361023 cursorCol = 0
10371024 }
1025+ dialogCursorRow, dialogCursorCol := dialogFocusRow, 0
1026+ if cursorInDialog {
1027+ dialogCursorRow, dialogCursorCol = cursorRow, cursorCol
1028+ }
10381029 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
10391030 if i.renderRevision.Load() != renderRevision {
10401031 i.mu.Unlock()
@@ -1044,13 +1035,17 @@ func (i *Interactive) redraw() {
10441035 // State assembly above is synchronized; terminal output is owned by
10451036 // this renderer goroutine and must not hold the global state mutex.
10461037 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)
10531043 }
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)
10541049 } else {
10551050 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
10561051 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
@@ -1063,6 +1058,41 @@ func (i *Interactive) redraw() {
10631058 }
10641059 i.mu.Unlock()
10651060 }
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+
10661096 func hasImageEscape(line string) bool {
10671097 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
10681098 }
@@ -492,76 +492,85 @@ func (i *Interactive) redraw() {
492 return492 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 []string500 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 3518+ 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 visible534+ 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 using560+ 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) > 0574 modalBackdrop := len(dialog) > 0
566 575
567 // Slash-command autocomplete: popup above the status line, only576 // 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 != nil707+ // 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 []string710 var allResidentSubagentLines []string
700 residentSubagentHidden := 0711 residentSubagentHidden := 0
701- if !dashboardActive && i.cfg.ResidentManager != nil {712+ if !residentViewActive && i.cfg.ResidentManager != nil {
702 const residentIndicatorSnapshotLimit = 8713 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 or763 // 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 = -1767 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.InputStyleLines770 lineInput := inputStyle == tui.InputStyleLines
@@ -814,7 +816,7 @@ func (i *Interactive) redraw() {
814 var residentSubagentLines []string816 var residentSubagentLines []string
815 var bottom []string817 var bottom []string
816 var inputStartRow int818 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's974 // Default: the real terminal cursor sits on the main editor's
984 // input position. In main-screen log mode cursor rows are relative975 // 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 above977+ // Floating content has its own coordinate space, so dialog cursor rows
987- // the dialog block (whenever a dialog is showing) so popup-side978+ // remain relative to the content passed to FloatingPane.Compose.
988- // cursor positions still land in the right cell.
989 dialogLead := 0979 dialogLead := 0
990- if len(dialog) > 0 {
991- dialogLead = 1
992- }
993 cursorRow := inputStartRow + inputCursorOffset + curR980 cursorRow := inputStartRow + inputCursorOffset + curR
994 cursorCol := curC + inputCursorColOffset981 cursorCol := curC + inputCursorColOffset
995 cursorInDialog := false982 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 + r985 cursorRow = dialogLead + r
999 cursorCol = c986 cursorCol = c
1000 cursorInDialog = true987 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 + r992 cursorRow = dialogLead + r
1006 cursorCol = c993 cursorCol = c
1007 cursorInDialog = true994 cursorInDialog = true
@@ -1035,6 +1022,10 @@ func (i *Interactive) redraw() {
1035 cursorRow = -11022 cursorRow = -1
1036 cursorCol = 01023 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 by1035 // 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) {
7676 d.provider = "anthropic"
7777 d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
7878
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)
8184 if row < 0 || row >= len(lines) {
8285 t.Fatalf("CursorPos row = %d outside rendered lines %d", row, len(lines))
8386 }
@@ -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 (
77 "github.com/bnema/zut/packages/tui"
88 )
99
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+
1018 func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
1119 term := &alertTestTerminal{}
1220 i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
@@ -23,9 +31,18 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
2331 if !strings.Contains(got, tui.Dim(input)) {
2432 t.Fatalf("settings dialog left the main input undimmed: %q", got)
2533 }
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)) {
2740 t.Fatalf("settings dialog itself was dimmed: %q", got)
2841 }
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+ }
2946 cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))
3047 if !strings.Contains(got, cursor) {
3148 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 {
2727
2828 // prevHadImage tracks whether the previous frame contained an
2929 // 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
3334
3435 // Main-screen flow renderer state. logLines is the full logical
3536 // buffer (chat + live bottom band) from the previous DrawLog call.
@@ -468,11 +469,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
468469 // its scrollbar to the top of the viewport on every image or
469470 // selection-highlight frame.
470471 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)
476477 }
477478 }
478479
@@ -523,10 +524,59 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
523524
524525 r.prev = frame
525526 r.prevHadImage = curHasImage
527+ r.prevHadKittyImage = curHasKittyImage
526528 r.cursorRow = cursorRow
527529 r.cursorCol = cursorCol
528530 }
529531
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+
530580 // DrawLog renders zut in the terminal's main screen as normal terminal
531581 // flow rather than a fixed full-screen frame. Chat lines are emitted once
532582 // 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 an28 // prevHadImage tracks whether the previous frame contained an
29 // inline-image escape so we can force a full clear+repaint whenever29 // inline-image escape so we can force a full clear+repaint whenever
30- // the image set changes. Only matters when inline images are30+ // 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 bool32+ prevHadImage bool
33+ prevHadKittyImage bool
33 34
34 // Main-screen flow renderer state. logLines is the full logical35 // 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 or469 // 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 image474+ // 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 = frame525 r.prev = frame
525 r.prevHadImage = curHasImage526 r.prevHadImage = curHasImage
527+ r.prevHadKittyImage = curHasKittyImage
526 r.cursorRow = cursorRow528 r.cursorRow = cursorRow
527 r.cursorCol = cursorCol529 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 terminal580 // 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 once581 // 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,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close