detached f294dc4

Merge pull request #135 from bnema/feat/floating-pane

feat(tui): add floating panes
commit
f294dc48f126fce2ce9e50f8d5cd495d5f530422
author
bnema · · 2026-08-14 11:58 UTC
committer
GitHub · · 2026-08-14 11:58 UTC
merge of
86b2df8 + 1555b7c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.18.0
18 files · +911 -128
M README.md +1 -1
@@ -260,7 +260,7 @@ When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspac
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 +25 -1
@@ -772,6 +772,7 @@ type Interactive struct {
772772 sessionDialog *sessionDialog
773773 residentSubagentsDialog *residentSubagentsDialog
774774 residentChildSession *residentChildSession
775+ residentChildMouse bool
775776 jumpDialog *jumpDialog
776777 btwDialog *btwDialog
777778 skillsDialog *skillsDialog
@@ -780,6 +781,7 @@ type Interactive struct {
780781 logoutDialog *logoutDialog
781782 telegramDialog *telegramDialog
782783 settingsDialog *settingsDialog
784+ floatingPane tui.FloatingPane
783785 quickModelAssign int
784786 telegramBridge *telegram.Bridge
785787 sessionOpsDialog *sessionOpsDialog
@@ -1062,6 +1064,28 @@ func (i *Interactive) markCtrlCExit() {
10621064 i.mu.Unlock()
10631065 }
10641066
1067+// setResidentChildMouseReporting enables mouse reporting only while the
1068+// transcript overlay owns scrolling. Normal chat keeps native terminal
1069+// selection and wheel scrolling intact.
1070+func (i *Interactive) setResidentChildMouseReporting(enabled bool) {
1071+ i.mu.Lock()
1072+ if i.residentChildMouse == enabled {
1073+ i.mu.Unlock()
1074+ return
1075+ }
1076+ i.residentChildMouse = enabled
1077+ term, running := i.cfg.Terminal, i.runCtx != nil
1078+ i.mu.Unlock()
1079+ if term == nil || !running {
1080+ return
1081+ }
1082+ seq := tui.SeqMouseOff
1083+ if enabled {
1084+ seq = tui.SeqMouseOn
1085+ }
1086+ _, _ = term.Write([]byte(seq))
1087+}
1088+
10651089 // Run blocks until the user quits.
10661090 func (i *Interactive) Run(ctx context.Context) error {
10671091 i.mu.Lock()
@@ -1102,7 +1126,7 @@ func (i *Interactive) Run(ctx context.Context) error {
11021126 // Erase the live frame and place the cursor deterministically before any
11031127 // exit summary or shell prompt is written. Do not erase scrollback: users
11041128 // should still be able to review the session after closing zut.
1105- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
1129+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
11061130 i.applyInputCursorColor()
11071131
11081132 // Streaming pacer: drains buffered text deltas at a steady rate
@@ -772,6 +772,7 @@ type Interactive struct {
772 sessionDialog *sessionDialog772 sessionDialog *sessionDialog
773 residentSubagentsDialog *residentSubagentsDialog773 residentSubagentsDialog *residentSubagentsDialog
774 residentChildSession *residentChildSession774 residentChildSession *residentChildSession
775+ residentChildMouse bool
775 jumpDialog *jumpDialog776 jumpDialog *jumpDialog
776 btwDialog *btwDialog777 btwDialog *btwDialog
777 skillsDialog *skillsDialog778 skillsDialog *skillsDialog
@@ -780,6 +781,7 @@ type Interactive struct {
780 logoutDialog *logoutDialog781 logoutDialog *logoutDialog
781 telegramDialog *telegramDialog782 telegramDialog *telegramDialog
782 settingsDialog *settingsDialog783 settingsDialog *settingsDialog
784+ floatingPane tui.FloatingPane
783 quickModelAssign int785 quickModelAssign int
784 telegramBridge *telegram.Bridge786 telegramBridge *telegram.Bridge
785 sessionOpsDialog *sessionOpsDialog787 sessionOpsDialog *sessionOpsDialog
@@ -1062,6 +1064,28 @@ func (i *Interactive) markCtrlCExit() {
1062 i.mu.Unlock()1064 i.mu.Unlock()
1063 }1065 }
1064 1066
1067+// setResidentChildMouseReporting enables mouse reporting only while the
1068+// transcript overlay owns scrolling. Normal chat keeps native terminal
1069+// selection and wheel scrolling intact.
1070+func (i *Interactive) setResidentChildMouseReporting(enabled bool) {
1071+ i.mu.Lock()
1072+ if i.residentChildMouse == enabled {
1073+ i.mu.Unlock()
1074+ return
1075+ }
1076+ i.residentChildMouse = enabled
1077+ term, running := i.cfg.Terminal, i.runCtx != nil
1078+ i.mu.Unlock()
1079+ if term == nil || !running {
1080+ return
1081+ }
1082+ seq := tui.SeqMouseOff
1083+ if enabled {
1084+ seq = tui.SeqMouseOn
1085+ }
1086+ _, _ = term.Write([]byte(seq))
1087+}
1088+
1065 // Run blocks until the user quits.1089 // Run blocks until the user quits.
1066 func (i *Interactive) Run(ctx context.Context) error {1090 func (i *Interactive) Run(ctx context.Context) error {
1067 i.mu.Lock()1091 i.mu.Lock()
@@ -1102,7 +1126,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1102 // Erase the live frame and place the cursor deterministically before any1126 // Erase the live frame and place the cursor deterministically before any
1103 // exit summary or shell prompt is written. Do not erase scrollback: users1127 // exit summary or shell prompt is written. Do not erase scrollback: users
1104 // should still be able to review the session after closing zut.1128 // should still be able to review the session after closing zut.
1105- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))1129+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
1106 i.applyInputCursorColor()1130 i.applyInputCursorColor()
1107 1131
1108 // Streaming pacer: drains buffered text deltas at a steady rate1132 // Streaming pacer: drains buffered text deltas at a steady rate
M packages/agent/modes/interactive_input_routing.go +13 -12
@@ -275,22 +275,23 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
275275 i.residentChildSession = nil
276276 }
277277 i.mu.Unlock()
278+ residentSession.Close()
279+ i.setResidentChildMouseReporting(false)
278280 case tui.KeyPageUp:
279- session := residentSession
280- if session.BeginLoad() {
281- go func() {
282- err := session.LoadOlder(200)
283- if session.FinishLoad(err) {
284- i.reloadResidentChildSession(session)
285- }
286- i.invalidate()
287- }()
288- }
281+ residentSession.Scroll(10)
289282 case tui.KeyPageDown:
290- residentSession.FollowTail()
283+ residentSession.Scroll(-10)
291284 case tui.KeyUp:
292- residentSession.Scroll(1)
285+ if !residentSession.HandleNavigation(k) {
286+ residentSession.Scroll(1)
287+ }
293288 case tui.KeyDown:
289+ if !residentSession.HandleNavigation(k) {
290+ residentSession.Scroll(-1)
291+ }
292+ case tui.KeyMouseWheelUp:
293+ residentSession.Scroll(1)
294+ case tui.KeyMouseWheelDown:
294295 residentSession.Scroll(-1)
295296 default:
296297 session := residentSession
@@ -275,22 +275,23 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
275 i.residentChildSession = nil275 i.residentChildSession = nil
276 }276 }
277 i.mu.Unlock()277 i.mu.Unlock()
278+ residentSession.Close()
279+ i.setResidentChildMouseReporting(false)
278 case tui.KeyPageUp:280 case tui.KeyPageUp:
279- session := residentSession281+ residentSession.Scroll(10)
280- if session.BeginLoad() {
281- go func() {
282- err := session.LoadOlder(200)
283- if session.FinishLoad(err) {
284- i.reloadResidentChildSession(session)
285- }
286- i.invalidate()
287- }()
288- }
289 case tui.KeyPageDown:282 case tui.KeyPageDown:
290- residentSession.FollowTail()283+ residentSession.Scroll(-10)
291 case tui.KeyUp:284 case tui.KeyUp:
292- residentSession.Scroll(1)285+ if !residentSession.HandleNavigation(k) {
286+ residentSession.Scroll(1)
287+ }
293 case tui.KeyDown:288 case tui.KeyDown:
289+ if !residentSession.HandleNavigation(k) {
290+ residentSession.Scroll(-1)
291+ }
292+ case tui.KeyMouseWheelUp:
293+ residentSession.Scroll(1)
294+ case tui.KeyMouseWheelDown:
294 residentSession.Scroll(-1)295 residentSession.Scroll(-1)
295 default:296 default:
296 session := residentSession297 session := residentSession
M packages/agent/modes/interactive_rendering.go +152 -82
@@ -492,75 +492,94 @@ 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+ if i.skillsDialog.Viewing() {
544+ dialogID = "skills-body"
545+ }
546+ dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth)
541547 case i.changelogDialog.Active():
542- dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
548+ dialogID = "changelog"
549+ dialog = i.changelogDialog.Render(i.cfg.Theme, dialogWidth)
543550 case i.logoutDialog.Active():
544- dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
551+ dialogID = "logout"
552+ dialog = i.logoutDialog.Render(i.cfg.Theme, dialogWidth)
545553 case i.telegramDialog.Active():
546- dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
554+ dialogID = "telegram"
555+ dialog = i.telegramDialog.Render(i.cfg.Theme, dialogWidth)
547556 case i.settingsDialog.Active():
548- dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
557+ dialogID = "settings"
558+ dialog = i.settingsDialog.Render(i.cfg.Theme, dialogWidth)
549559 case i.sessionOpsDialog.Active():
550- dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
560+ dialogID = "session-ops"
561+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, dialogWidth)
551562 case i.sessionTreeDialog.Active():
552- // Reserve rows for the editor, status, and tree chrome while using
553- // the same budget for rendering and PageUp/PageDown movement.
554- _, rows := i.cfg.Terminal.Size()
555- avail := rows - 12
556- if avail < 3 {
557- avail = 3
558- }
559- i.sessionTreeDialog.MaxRows = avail
560- dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
563+ dialogID = "session-tree"
564+ i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
565+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)
561566 }
567+ dialogTitle := floatingOverlayTitle(dialogID)
568+ dialogRemovedTopRows := 0
562569 if len(dialog) > 0 {
563570 dialog = padDialogFrame(dialog)
571+ frameTitle, body, removedTopRows := floatingDialogBody(dialog)
572+ if frameTitle != "" {
573+ dialogTitle = frameTitle
574+ }
575+ dialog, dialogRemovedTopRows = body, removedTopRows
576+ }
577+ dialogFocusRow := -1
578+ for row, line := range dialog {
579+ if strings.Contains(line, i.cfg.Theme.SelectionStyle()) {
580+ dialogFocusRow = row
581+ break
582+ }
564583 }
565584 modalBackdrop := len(dialog) > 0
566585
@@ -695,10 +714,12 @@ func (i *Interactive) redraw() {
695714 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
696715 }
697716 edLines, curR, curC := i.ed.Render(mainCols)
698- dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
717+ // Floating independent views no longer replace the main bottom band: the
718+ // underlying chat, status, and editor keep updating behind the pane.
719+ residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
699720 var allResidentSubagentLines []string
700721 residentSubagentHidden := 0
701- if !dashboardActive && i.cfg.ResidentManager != nil {
722+ if !residentViewActive && i.cfg.ResidentManager != nil {
702723 const residentIndicatorSnapshotLimit = 8
703724 snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
704725 allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
@@ -752,17 +773,7 @@ func (i *Interactive) redraw() {
752773 // blanks so spacing stays consistent whether or not a dialog or
753774 // popup is showing.
754775 composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
755- bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
756- inputStartRow = -1
757- if len(dialog) > 0 {
758- bottom = append(bottom, "")
759- }
760- bottom = append(bottom, dialog...)
761- // The resident child views own the bottom of the screen while active.
762- if dashboardActive {
763- return bottom, inputStartRow
764- }
765-
776+ bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
766777 bottom = append(bottom, suggest...)
767778 bottom = append(bottom, queue...)
768779 lineInput := inputStyle == tui.InputStyleLines
@@ -814,7 +825,7 @@ func (i *Interactive) redraw() {
814825 var residentSubagentLines []string
815826 var bottom []string
816827 var inputStartRow int
817- if !dashboardActive && len(allResidentSubagentLines) > 0 {
828+ if len(allResidentSubagentLines) > 0 {
818829 for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
819830 candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
820831 candidateBottom, _ := composeBottom(candidate)
@@ -969,39 +980,24 @@ func (i *Interactive) redraw() {
969980 }
970981 }
971982
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-
983983 // Default: the real terminal cursor sits on the main editor's
984984 // input position. In main-screen log mode cursor rows are relative
985985 // to the fixed bottom band, not the chat transcript.
986- // dialogLead is 1 when the bottom region prepends a blank above
987- // the dialog block (whenever a dialog is showing) so popup-side
988- // cursor positions still land in the right cell.
986+ // Floating content has its own coordinate space, so dialog cursor rows
987+ // remain relative to the content passed to FloatingPane.Compose.
989988 dialogLead := 0
990- if len(dialog) > 0 {
991- dialogLead = 1
992- }
993989 cursorRow := inputStartRow + inputCursorOffset + curR
994990 cursorCol := curC + inputCursorColOffset
995991 cursorInDialog := false
996992 if i.btwDialog.Active() {
997- if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
993+ if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 {
998994 cursorRow = dialogLead + r
999995 cursorCol = c
1000996 cursorInDialog = true
1001997 }
1002998 }
1003999 if i.dialog.Active() {
1004- if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
1000+ if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 {
10051001 cursorRow = dialogLead + r
10061002 cursorCol = c
10071003 cursorInDialog = true
@@ -1035,6 +1031,13 @@ func (i *Interactive) redraw() {
10351031 cursorRow = -1
10361032 cursorCol = 0
10371033 }
1034+ dialogCursorRow, dialogCursorCol := dialogFocusRow, 0
1035+ if cursorInDialog {
1036+ dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol
1037+ if dialogCursorRow < 0 {
1038+ dialogCursorRow = -1
1039+ }
1040+ }
10381041 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
10391042 if i.renderRevision.Load() != renderRevision {
10401043 i.mu.Unlock()
@@ -1044,13 +1047,17 @@ func (i *Interactive) redraw() {
10441047 // State assembly above is synchronized; terminal output is owned by
10451048 // this renderer goroutine and must not hold the global state mutex.
10461049 i.mu.Unlock()
1047- if rightBarActive {
1048- rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1049- if modalBackdrop {
1050- i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
1051- } else {
1052- i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
1050+ if modalBackdrop {
1051+ background := floatingBackgroundFrame(visibleChat, bottom, rows)
1052+ if rightBarActive {
1053+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1054+ background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth)
10531055 }
1056+ pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)
1057+ i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol)
1058+ } else if rightBarActive {
1059+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1060+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
10541061 } else {
10551062 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
10561063 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
@@ -1063,6 +1070,69 @@ func (i *Interactive) redraw() {
10631070 }
10641071 i.mu.Unlock()
10651072 }
1073+
1074+// floatingOverlayTitle supplies every overlay with a stable, human-readable
1075+// title even when its body does not use the legacy frame-header convention.
1076+func floatingOverlayTitle(id string) string {
1077+ return strings.ReplaceAll(id, "-", " ")
1078+}
1079+
1080+// floatingDialogBody removes a legacy dialog's rule chrome and returns its
1081+// title for the shared floating-pane border.
1082+func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {
1083+ if len(lines) == 0 {
1084+ return "", nil, 0
1085+ }
1086+ body = lines
1087+ if isFrameHeaderLine(body[0]) {
1088+ plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ")
1089+ title = strings.TrimSpace(strings.TrimRight(plain, "─"))
1090+ body = body[1:]
1091+ removedTopRows = 1
1092+ }
1093+ if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) {
1094+ body = body[:last]
1095+ }
1096+ return title, body, removedTopRows
1097+}
1098+
1099+// floatingBackgroundFrame preserves the regular viewport layout while an
1100+// independent view is open. It is a fixed terminal frame only for the overlay
1101+// lifetime; normal rendering immediately returns to scrollback-oriented flow
1102+// when the pane closes.
1103+func floatingBackgroundFrame(chat, bottom []string, rows int) []string {
1104+ if rows < 1 {
1105+ return nil
1106+ }
1107+ // Match DrawLog's logical order and renderer-owned bottom margin, then
1108+ // retain exactly the terminal viewport. In particular, an editor/status
1109+ // band taller than the terminal must show its newest rows, not its oldest.
1110+ lines := make([]string, 0, len(chat)+len(bottom)+1)
1111+ lines = append(lines, chat...)
1112+ lines = append(lines, bottom...)
1113+ lines = append(lines, "")
1114+ if len(lines) > rows {
1115+ lines = lines[len(lines)-rows:]
1116+ }
1117+ frame := make([]string, rows)
1118+ // DrawLog's flow renderer leaves the logical session content at the
1119+ // viewport's top. Keep that anchoring when switching to a fixed floating
1120+ // frame so opening a pane does not visibly jump the whole session down.
1121+ copy(frame, lines)
1122+ return frame
1123+}
1124+
1125+func floatingRightBarFrame(theme tui.Theme, main, rightBar []string, mainWidth, rightBarWidth int) []string {
1126+ for row := range main {
1127+ barLine := ""
1128+ if row < len(rightBar) {
1129+ barLine = rightBar[row]
1130+ }
1131+ main[row] = tui.JoinRightBar(theme, main[row], barLine, mainWidth, rightBarWidth)
1132+ }
1133+ return main
1134+}
1135+
10661136 func hasImageEscape(line string) bool {
10671137 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
10681138 }
@@ -492,75 +492,94 @@ 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+ if i.skillsDialog.Viewing() {
544+ dialogID = "skills-body"
545+ }
546+ dialog = i.skillsDialog.Render(i.cfg.Theme, dialogWidth)
541 case i.changelogDialog.Active():547 case i.changelogDialog.Active():
542- dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)548+ dialogID = "changelog"
549+ dialog = i.changelogDialog.Render(i.cfg.Theme, dialogWidth)
543 case i.logoutDialog.Active():550 case i.logoutDialog.Active():
544- dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)551+ dialogID = "logout"
552+ dialog = i.logoutDialog.Render(i.cfg.Theme, dialogWidth)
545 case i.telegramDialog.Active():553 case i.telegramDialog.Active():
546- dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)554+ dialogID = "telegram"
555+ dialog = i.telegramDialog.Render(i.cfg.Theme, dialogWidth)
547 case i.settingsDialog.Active():556 case i.settingsDialog.Active():
548- dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)557+ dialogID = "settings"
558+ dialog = i.settingsDialog.Render(i.cfg.Theme, dialogWidth)
549 case i.sessionOpsDialog.Active():559 case i.sessionOpsDialog.Active():
550- dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)560+ dialogID = "session-ops"
561+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, dialogWidth)
551 case i.sessionTreeDialog.Active():562 case i.sessionTreeDialog.Active():
552- // Reserve rows for the editor, status, and tree chrome while using563+ dialogID = "session-tree"
553- // the same budget for rendering and PageUp/PageDown movement.564+ i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
554- _, rows := i.cfg.Terminal.Size()565+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)
555- avail := rows - 12
556- if avail < 3 {
557- avail = 3
558- }
559- i.sessionTreeDialog.MaxRows = avail
560- dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
561 }566 }
567+ dialogTitle := floatingOverlayTitle(dialogID)
568+ dialogRemovedTopRows := 0
562 if len(dialog) > 0 {569 if len(dialog) > 0 {
563 dialog = padDialogFrame(dialog)570 dialog = padDialogFrame(dialog)
571+ frameTitle, body, removedTopRows := floatingDialogBody(dialog)
572+ if frameTitle != "" {
573+ dialogTitle = frameTitle
574+ }
575+ dialog, dialogRemovedTopRows = body, removedTopRows
576+ }
577+ dialogFocusRow := -1
578+ for row, line := range dialog {
579+ if strings.Contains(line, i.cfg.Theme.SelectionStyle()) {
580+ dialogFocusRow = row
581+ break
582+ }
564 }583 }
565 modalBackdrop := len(dialog) > 0584 modalBackdrop := len(dialog) > 0
566 585
@@ -695,10 +714,12 @@ func (i *Interactive) redraw() {
695 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)714 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
696 }715 }
697 edLines, curR, curC := i.ed.Render(mainCols)716 edLines, curR, curC := i.ed.Render(mainCols)
698- dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil717+ // Floating independent views no longer replace the main bottom band: the
718+ // underlying chat, status, and editor keep updating behind the pane.
719+ residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
699 var allResidentSubagentLines []string720 var allResidentSubagentLines []string
700 residentSubagentHidden := 0721 residentSubagentHidden := 0
701- if !dashboardActive && i.cfg.ResidentManager != nil {722+ if !residentViewActive && i.cfg.ResidentManager != nil {
702 const residentIndicatorSnapshotLimit = 8723 const residentIndicatorSnapshotLimit = 8
703 snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)724 snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
704 allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())725 allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
@@ -752,17 +773,7 @@ func (i *Interactive) redraw() {
752 // blanks so spacing stays consistent whether or not a dialog or773 // blanks so spacing stays consistent whether or not a dialog or
753 // popup is showing.774 // popup is showing.
754 composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {775 composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
755- bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)776+ bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
756- inputStartRow = -1
757- if len(dialog) > 0 {
758- bottom = append(bottom, "")
759- }
760- bottom = append(bottom, dialog...)
761- // The resident child views own the bottom of the screen while active.
762- if dashboardActive {
763- return bottom, inputStartRow
764- }
765-
766 bottom = append(bottom, suggest...)777 bottom = append(bottom, suggest...)
767 bottom = append(bottom, queue...)778 bottom = append(bottom, queue...)
768 lineInput := inputStyle == tui.InputStyleLines779 lineInput := inputStyle == tui.InputStyleLines
@@ -814,7 +825,7 @@ func (i *Interactive) redraw() {
814 var residentSubagentLines []string825 var residentSubagentLines []string
815 var bottom []string826 var bottom []string
816 var inputStartRow int827 var inputStartRow int
817- if !dashboardActive && len(allResidentSubagentLines) > 0 {828+ if len(allResidentSubagentLines) > 0 {
818 for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {829 for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
819 candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)830 candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
820 candidateBottom, _ := composeBottom(candidate)831 candidateBottom, _ := composeBottom(candidate)
@@ -969,39 +980,24 @@ func (i *Interactive) redraw() {
969 }980 }
970 }981 }
971 982
972- // A dialog is the foreground layer. Dim every other visible surface,
973- // including the main input beneath it, without changing the dialog's
974- // own styling. Keep the source slices untouched because chat is cached.
975- if modalBackdrop {
976- chat = tui.DimLines(chat)
977- visibleChat = tui.DimLines(visibleChat)
978- dimmedBottom := tui.DimLines(bottom)
979- copy(dimmedBottom[1:1+len(dialog)], dialog)
980- bottom = dimmedBottom
981- }
982-
983 // Default: the real terminal cursor sits on the main editor's983 // Default: the real terminal cursor sits on the main editor's
984 // input position. In main-screen log mode cursor rows are relative984 // input position. In main-screen log mode cursor rows are relative
985 // to the fixed bottom band, not the chat transcript.985 // to the fixed bottom band, not the chat transcript.
986- // dialogLead is 1 when the bottom region prepends a blank above986+ // Floating content has its own coordinate space, so dialog cursor rows
987- // the dialog block (whenever a dialog is showing) so popup-side987+ // remain relative to the content passed to FloatingPane.Compose.
988- // cursor positions still land in the right cell.
989 dialogLead := 0988 dialogLead := 0
990- if len(dialog) > 0 {
991- dialogLead = 1
992- }
993 cursorRow := inputStartRow + inputCursorOffset + curR989 cursorRow := inputStartRow + inputCursorOffset + curR
994 cursorCol := curC + inputCursorColOffset990 cursorCol := curC + inputCursorColOffset
995 cursorInDialog := false991 cursorInDialog := false
996 if i.btwDialog.Active() {992 if i.btwDialog.Active() {
997- if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {993+ if r, c := i.btwDialog.CursorPos(dialogWidth); r >= 0 {
998 cursorRow = dialogLead + r994 cursorRow = dialogLead + r
999 cursorCol = c995 cursorCol = c
1000 cursorInDialog = true996 cursorInDialog = true
1001 }997 }
1002 }998 }
1003 if i.dialog.Active() {999 if i.dialog.Active() {
1004- if r, c := i.dialog.CursorPos(mainCols); r >= 0 {1000+ if r, c := i.dialog.CursorPos(dialogWidth); r >= 0 {
1005 cursorRow = dialogLead + r1001 cursorRow = dialogLead + r
1006 cursorCol = c1002 cursorCol = c
1007 cursorInDialog = true1003 cursorInDialog = true
@@ -1035,6 +1031,13 @@ func (i *Interactive) redraw() {
1035 cursorRow = -11031 cursorRow = -1
1036 cursorCol = 01032 cursorCol = 0
1037 }1033 }
1034+ dialogCursorRow, dialogCursorCol := dialogFocusRow, 0
1035+ if cursorInDialog {
1036+ dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol
1037+ if dialogCursorRow < 0 {
1038+ dialogCursorRow = -1
1039+ }
1040+ }
1038 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)1041 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
1039 if i.renderRevision.Load() != renderRevision {1042 if i.renderRevision.Load() != renderRevision {
1040 i.mu.Unlock()1043 i.mu.Unlock()
@@ -1044,13 +1047,17 @@ func (i *Interactive) redraw() {
1044 // State assembly above is synchronized; terminal output is owned by1047 // State assembly above is synchronized; terminal output is owned by
1045 // this renderer goroutine and must not hold the global state mutex.1048 // this renderer goroutine and must not hold the global state mutex.
1046 i.mu.Unlock()1049 i.mu.Unlock()
1047- if rightBarActive {1050+ if modalBackdrop {
1048- rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)1051+ background := floatingBackgroundFrame(visibleChat, bottom, rows)
1049- if modalBackdrop {1052+ if rightBarActive {
1050- i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)1053+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1051- } else {1054+ background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth)
1052- i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
1053 }1055 }
1056+ pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)
1057+ i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol)
1058+ } else if rightBarActive {
1059+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1060+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
1054 } else {1061 } else {
1055 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.1062 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1056 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)1063 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
@@ -1063,6 +1070,69 @@ func (i *Interactive) redraw() {
1063 }1070 }
1064 i.mu.Unlock()1071 i.mu.Unlock()
1065 }1072 }
1073+
1074+// floatingOverlayTitle supplies every overlay with a stable, human-readable
1075+// title even when its body does not use the legacy frame-header convention.
1076+func floatingOverlayTitle(id string) string {
1077+ return strings.ReplaceAll(id, "-", " ")
1078+}
1079+
1080+// floatingDialogBody removes a legacy dialog's rule chrome and returns its
1081+// title for the shared floating-pane border.
1082+func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {
1083+ if len(lines) == 0 {
1084+ return "", nil, 0
1085+ }
1086+ body = lines
1087+ if isFrameHeaderLine(body[0]) {
1088+ plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ")
1089+ title = strings.TrimSpace(strings.TrimRight(plain, "─"))
1090+ body = body[1:]
1091+ removedTopRows = 1
1092+ }
1093+ if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) {
1094+ body = body[:last]
1095+ }
1096+ return title, body, removedTopRows
1097+}
1098+
1099+// floatingBackgroundFrame preserves the regular viewport layout while an
1100+// independent view is open. It is a fixed terminal frame only for the overlay
1101+// lifetime; normal rendering immediately returns to scrollback-oriented flow
1102+// when the pane closes.
1103+func floatingBackgroundFrame(chat, bottom []string, rows int) []string {
1104+ if rows < 1 {
1105+ return nil
1106+ }
1107+ // Match DrawLog's logical order and renderer-owned bottom margin, then
1108+ // retain exactly the terminal viewport. In particular, an editor/status
1109+ // band taller than the terminal must show its newest rows, not its oldest.
1110+ lines := make([]string, 0, len(chat)+len(bottom)+1)
1111+ lines = append(lines, chat...)
1112+ lines = append(lines, bottom...)
1113+ lines = append(lines, "")
1114+ if len(lines) > rows {
1115+ lines = lines[len(lines)-rows:]
1116+ }
1117+ frame := make([]string, rows)
1118+ // DrawLog's flow renderer leaves the logical session content at the
1119+ // viewport's top. Keep that anchoring when switching to a fixed floating
1120+ // frame so opening a pane does not visibly jump the whole session down.
1121+ copy(frame, lines)
1122+ return frame
1123+}
1124+
1125+func floatingRightBarFrame(theme tui.Theme, main, rightBar []string, mainWidth, rightBarWidth int) []string {
1126+ for row := range main {
1127+ barLine := ""
1128+ if row < len(rightBar) {
1129+ barLine = rightBar[row]
1130+ }
1131+ main[row] = tui.JoinRightBar(theme, main[row], barLine, mainWidth, rightBarWidth)
1132+ }
1133+ return main
1134+}
1135+
1066 func hasImageEscape(line string) bool {1136 func hasImageEscape(line string) bool {
1067 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")1137 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1068 }1138 }
M packages/agent/modes/interactive_resident_subagents.go +1 -1
@@ -64,7 +64,7 @@ func (i *Interactive) reloadResidentChildSession(session *residentChildSession)
6464 return
6565 }
6666 go func() {
67- err := session.ReloadRecent(200)
67+ err := session.ReloadAll(200, i.invalidate)
6868 if session.FinishLoad(err) {
6969 if session.RequestRecentReload() {
7070 i.reloadResidentChildSession(session)
@@ -64,7 +64,7 @@ func (i *Interactive) reloadResidentChildSession(session *residentChildSession)
64 return64 return
65 }65 }
66 go func() {66 go func() {
67- err := session.ReloadRecent(200)67+ err := session.ReloadAll(200, i.invalidate)
68 if session.FinishLoad(err) {68 if session.FinishLoad(err) {
69 if session.RequestRecentReload() {69 if session.RequestRecentReload() {
70 i.reloadResidentChildSession(session)70 i.reloadResidentChildSession(session)
M packages/agent/modes/interactive_terminal_test.go +1 -1
@@ -38,7 +38,7 @@ func TestInteractiveRunClearsVisibleFrameOnExit(t *testing.T) {
3838 t.Fatalf("Run error = %v, want context.Canceled", err)
3939 }
4040
41- cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages +
41+ cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff +
4242 tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff +
4343 tui.ResetCursorColor() + tui.ResetCursorShape() +
4444 tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
@@ -38,7 +38,7 @@ func TestInteractiveRunClearsVisibleFrameOnExit(t *testing.T) {
38 t.Fatalf("Run error = %v, want context.Canceled", err)38 t.Fatalf("Run error = %v, want context.Canceled", err)
39 }39 }
40 40
41- cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages +41+ cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff +
42 tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff +42 tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff +
43 tui.ResetCursorColor() + tui.ResetCursorShape() +43 tui.ResetCursorColor() + tui.ResetCursorShape() +
44 tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor44 tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
M packages/agent/modes/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 +64 -2
@@ -7,6 +7,56 @@ import (
77 "github.com/bnema/zut/packages/tui"
88 )
99
10+func TestFloatingOverlayTitleFallsBackToOverlayIdentity(t *testing.T) {
11+ if got := floatingOverlayTitle("subagent-session"); got != "subagent session" {
12+ t.Fatalf("overlay title = %q, want subagent session", got)
13+ }
14+}
15+
16+func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) {
17+ lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)})
18+ title, body, removed := floatingDialogBody(lines)
19+ if title != "settings" || removed != 1 {
20+ t.Fatalf("chrome extraction = title:%q removed:%d, want settings/1", title, removed)
21+ }
22+ if strings.Join(body, "\n") != "\nbody\n" {
23+ t.Fatalf("dialog body retained frame chrome: %q", body)
24+ }
25+}
26+
27+func TestFloatingBackgroundFramePreservesTopAnchoredViewport(t *testing.T) {
28+ tests := []struct {
29+ name string
30+ chat []string
31+ bottom []string
32+ rows int
33+ want []string
34+ }{
35+ {
36+ name: "session stays at top",
37+ chat: []string{"chat"},
38+ bottom: []string{"status"},
39+ rows: 5,
40+ want: []string{"chat", "status", "", "", ""},
41+ },
42+ {
43+ name: "oversized bottom retains newest rows",
44+ chat: []string{"chat"},
45+ bottom: []string{"old", "newer", "newest"},
46+ rows: 3,
47+ want: []string{"newer", "newest", ""},
48+ },
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ got := floatingBackgroundFrame(tt.chat, tt.bottom, tt.rows)
53+ if strings.Join(got, "\n") != strings.Join(tt.want, "\n") {
54+ t.Fatalf("floating background = %q, want %q", got, tt.want)
55+ }
56+ })
57+ }
58+}
59+
1060 func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
1161 term := &alertTestTerminal{}
1262 i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
@@ -23,8 +73,20 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
2373 if !strings.Contains(got, tui.Dim(input)) {
2474 t.Fatalf("settings dialog left the main input undimmed: %q", got)
2575 }
26- if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) {
27- t.Fatalf("settings dialog itself was dimmed: %q", got)
76+ pane := tui.FloatingPaneMaxRect(80, 24)
77+ innerHeader := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth())
78+ if strings.Contains(got, innerHeader) {
79+ t.Fatalf("settings retained its inner frame header: %q", got)
80+ }
81+ if !strings.Contains(got, " settings ") {
82+ t.Fatalf("settings title was not rendered in the floating border: %q", got)
83+ }
84+ if strings.Contains(got, tui.Dim(" settings ")) {
85+ t.Fatalf("floating pane title was dimmed: %q", got)
86+ }
87+ // The pane shrinks to its chrome-free content and remains centered.
88+ if !strings.Contains(got, tui.MoveTo(10, pane.X+1)) {
89+ t.Fatalf("floating pane was not positioned at its computed geometry: %q", got)
2890 }
2991 cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))
3092 if !strings.Contains(got, cursor) {
@@ -7,6 +7,56 @@ import (
7 "github.com/bnema/zut/packages/tui"7 "github.com/bnema/zut/packages/tui"
8 )8 )
9 9
10+func TestFloatingOverlayTitleFallsBackToOverlayIdentity(t *testing.T) {
11+ if got := floatingOverlayTitle("subagent-session"); got != "subagent session" {
12+ t.Fatalf("overlay title = %q, want subagent session", got)
13+ }
14+}
15+
16+func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) {
17+ lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)})
18+ title, body, removed := floatingDialogBody(lines)
19+ if title != "settings" || removed != 1 {
20+ t.Fatalf("chrome extraction = title:%q removed:%d, want settings/1", title, removed)
21+ }
22+ if strings.Join(body, "\n") != "\nbody\n" {
23+ t.Fatalf("dialog body retained frame chrome: %q", body)
24+ }
25+}
26+
27+func TestFloatingBackgroundFramePreservesTopAnchoredViewport(t *testing.T) {
28+ tests := []struct {
29+ name string
30+ chat []string
31+ bottom []string
32+ rows int
33+ want []string
34+ }{
35+ {
36+ name: "session stays at top",
37+ chat: []string{"chat"},
38+ bottom: []string{"status"},
39+ rows: 5,
40+ want: []string{"chat", "status", "", "", ""},
41+ },
42+ {
43+ name: "oversized bottom retains newest rows",
44+ chat: []string{"chat"},
45+ bottom: []string{"old", "newer", "newest"},
46+ rows: 3,
47+ want: []string{"newer", "newest", ""},
48+ },
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ got := floatingBackgroundFrame(tt.chat, tt.bottom, tt.rows)
53+ if strings.Join(got, "\n") != strings.Join(tt.want, "\n") {
54+ t.Fatalf("floating background = %q, want %q", got, tt.want)
55+ }
56+ })
57+ }
58+}
59+
10 func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {60 func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
11 term := &alertTestTerminal{}61 term := &alertTestTerminal{}
12 i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})62 i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
@@ -23,8 +73,20 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
23 if !strings.Contains(got, tui.Dim(input)) {73 if !strings.Contains(got, tui.Dim(input)) {
24 t.Fatalf("settings dialog left the main input undimmed: %q", got)74 t.Fatalf("settings dialog left the main input undimmed: %q", got)
25 }75 }
26- if header := frameHeader(i.cfg.Theme, "settings", 80); strings.Contains(got, tui.Dim(header)) {76+ pane := tui.FloatingPaneMaxRect(80, 24)
27- t.Fatalf("settings dialog itself was dimmed: %q", got)77+ innerHeader := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth())
78+ if strings.Contains(got, innerHeader) {
79+ t.Fatalf("settings retained its inner frame header: %q", got)
80+ }
81+ if !strings.Contains(got, " settings ") {
82+ t.Fatalf("settings title was not rendered in the floating border: %q", got)
83+ }
84+ if strings.Contains(got, tui.Dim(" settings ")) {
85+ t.Fatalf("floating pane title was dimmed: %q", got)
86+ }
87+ // The pane shrinks to its chrome-free content and remains centered.
88+ if !strings.Contains(got, tui.MoveTo(10, pane.X+1)) {
89+ t.Fatalf("floating pane was not positioned at its computed geometry: %q", got)
28 }90 }
29 cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))91 cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))
30 if !strings.Contains(got, cursor) {92 if !strings.Contains(got, cursor) {
M packages/agent/modes/resident_subagent_indicator.go +4 -1
@@ -22,7 +22,10 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
2222 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
2323 lines := make([]string, 0, len(snapshots))
2424 for _, snapshot := range snapshots {
25- if snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning {
25+ // Foreign journals remain discoverable through /subagents, but their
26+ // activity belongs to another zut host and must not occupy this
27+ // session's always-visible editor-adjacent indicator.
28+ if snapshot.OwnedElsewhere || (snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning) {
2629 continue
2730 }
2831 name := sanitizeSessionTreeText(snapshot.Profile)
@@ -22,7 +22,10 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
22 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {22 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
23 lines := make([]string, 0, len(snapshots))23 lines := make([]string, 0, len(snapshots))
24 for _, snapshot := range snapshots {24 for _, snapshot := range snapshots {
25- if snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning {25+ // Foreign journals remain discoverable through /subagents, but their
26+ // activity belongs to another zut host and must not occupy this
27+ // session's always-visible editor-adjacent indicator.
28+ if snapshot.OwnedElsewhere || (snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning) {
26 continue29 continue
27 }30 }
28 name := sanitizeSessionTreeText(snapshot.Profile)31 name := sanitizeSessionTreeText(snapshot.Profile)
M packages/agent/modes/resident_subagent_indicator_test.go +3 -2
@@ -14,14 +14,15 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T
1414 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{
1515 {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},
1616 {ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},
17+ {ID: "foreign-id", Profile: "another process", State: subagents.ResidentRunning, OwnedElsewhere: true, TurnStartedAt: now.Add(-10 * time.Second)},
1718 {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},
1819 }, 80, now)
1920 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
2021 if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {
2122 t.Fatalf("activity lines = %q", got)
2223 }
23- if strings.Contains(got, "finished") {
24- t.Fatalf("activity lines include inactive child: %q", got)
24+ if strings.Contains(got, "finished") || strings.Contains(got, "another process") {
25+ t.Fatalf("activity lines include inactive or foreign child: %q", got)
2526 }
2627 }
2728
@@ -14,14 +14,15 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T
14 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{14 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{
15 {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},15 {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},
16 {ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},16 {ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},
17+ {ID: "foreign-id", Profile: "another process", State: subagents.ResidentRunning, OwnedElsewhere: true, TurnStartedAt: now.Add(-10 * time.Second)},
17 {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},18 {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},
18 }, 80, now)19 }, 80, now)
19 got := strings.Join(plainResidentIndicatorLines(lines), "\n")20 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
20 if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {21 if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {
21 t.Fatalf("activity lines = %q", got)22 t.Fatalf("activity lines = %q", got)
22 }23 }
23- if strings.Contains(got, "finished") {24+ if strings.Contains(got, "finished") || strings.Contains(got, "another process") {
24- t.Fatalf("activity lines include inactive child: %q", got)25+ t.Fatalf("activity lines include inactive or foreign child: %q", got)
25 }26 }
26 }27 }
27 28
M packages/agent/modes/skills_dialog.go +2 -1
@@ -34,7 +34,8 @@ func (d *skillsDialog) Open(s []*skills.Skill) {
3434 func (d *skillsDialog) Close() { d.active = false }
3535
3636 // Active reports whether the dialog is visible.
37-func (d *skillsDialog) Active() bool { return d != nil && d.active }
37+func (d *skillsDialog) Active() bool { return d != nil && d.active }
38+func (d *skillsDialog) Viewing() bool { return d != nil && d.viewing != nil }
3839
3940 // HandleKey advances the dialog state.
4041 func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
@@ -34,7 +34,8 @@ func (d *skillsDialog) Open(s []*skills.Skill) {
34 func (d *skillsDialog) Close() { d.active = false }34 func (d *skillsDialog) Close() { d.active = false }
35 35
36 // Active reports whether the dialog is visible.36 // Active reports whether the dialog is visible.
37-func (d *skillsDialog) Active() bool { return d != nil && d.active }37+func (d *skillsDialog) Active() bool { return d != nil && d.active }
38+func (d *skillsDialog) Viewing() bool { return d != nil && d.viewing != nil }
38 39
39 // HandleKey advances the dialog state.40 // HandleKey advances the dialog state.
40 func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {41 func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
M packages/agent/modes/subagent_session.go +113 -11
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "context"
45 "fmt"
56 "strings"
67 "sync"
@@ -27,14 +28,31 @@ type residentChildSession struct {
2728 // scrollOffset is measured from the rendered live tail. Keeping the
2829 // viewport bottom-relative preserves the same finalized content when an
2930 // older page is prepended or the active turn grows.
30- scrollOffset int
31- unread int
32- lastRows int
33- recentRows int
31+ scrollOffset int
32+ unread int
33+ lastRows int
34+ recentRows int
35+ liveRevision uint64
36+ livePresent bool
37+ cacheWidth int
38+ cacheMessagesRevision uint64
39+ cacheMessageCount int
40+ cacheLiveRevision uint64
41+ cacheLivePresent bool
42+ cacheRows []string
43+ loadCtx context.Context
44+ cancelLoad context.CancelFunc
3445 }
3546
3647 func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {
37- return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > ")}
48+ ctx, cancel := context.WithCancel(context.Background())
49+ return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > "), loadCtx: ctx, cancelLoad: cancel}
50+}
51+
52+func (s *residentChildSession) Close() {
53+ if s != nil && s.cancelLoad != nil {
54+ s.cancelLoad()
55+ }
3856 }
3957
4058 func (s *residentChildSession) LoadRecent(limit int) error {
@@ -60,6 +78,60 @@ func (s *residentChildSession) ReloadRecent(limit int) error {
6078 return s.LoadRecent(limit)
6179 }
6280
81+// LoadAll progressively installs the newest page then prepends every older
82+// page. The callback runs after each durable page so the floating pane grows
83+// naturally while disk history is being reconstructed.
84+func (s *residentChildSession) LoadAll(limit int, progressed func()) error {
85+ if s.loadCtx != nil {
86+ select {
87+ case <-s.loadCtx.Done():
88+ return s.loadCtx.Err()
89+ default:
90+ }
91+ }
92+ if err := s.LoadRecent(limit); err != nil {
93+ return err
94+ }
95+ if progressed != nil {
96+ progressed()
97+ }
98+ for {
99+ if s.loadCtx != nil {
100+ select {
101+ case <-s.loadCtx.Done():
102+ return s.loadCtx.Err()
103+ default:
104+ }
105+ }
106+ s.mu.Lock()
107+ more := s.olderCursor != ""
108+ s.mu.Unlock()
109+ if !more {
110+ return nil
111+ }
112+ if err := s.LoadOlder(limit); err != nil {
113+ return err
114+ }
115+ if progressed != nil {
116+ progressed()
117+ }
118+ }
119+}
120+
121+// ReloadAll rebuilds from one journal generation instead of replacing a
122+// projected-message suffix whose page boundary may not match tool projection.
123+func (s *residentChildSession) ReloadAll(limit int, progressed func()) error {
124+ s.mu.Lock()
125+ s.view.Messages = nil
126+ s.view.MessagesRevision++
127+ s.olderCursor = ""
128+ s.recentRows = 0
129+ // Keep a reader's bottom-relative viewport across a durable reload.
130+ // Render clamps it against the rebuilt rows; a tail follower remains at 0.
131+ s.mu.Unlock()
132+ return s.LoadAll(limit, progressed)
133+}
134+
63135 func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {
64136 if s == nil {
65137 return
@@ -116,8 +188,12 @@ func (s *residentChildSession) refreshLiveLocked() {
116188 s.view.Streaming = ""
117189 s.view.StreamingActive = false
118190 s.view.ToolCalls = nil
191+ s.liveRevision = 0
192+ s.livePresent = false
119193 return
120194 }
195+ s.liveRevision = live.Revision
196+ s.livePresent = true
121197 s.view.Streaming = live.AssistantText
122198 s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""
123199 s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))
@@ -126,6 +202,27 @@ func (s *residentChildSession) refreshLiveLocked() {
126202 }
127203 }
128204
205+func (s *residentChildSession) renderedHistory(width int) []string {
206+ s.mu.Lock()
207+ s.refreshLiveLocked()
208+ if s.cacheWidth == width && s.cacheMessagesRevision == s.view.MessagesRevision && s.cacheMessageCount == len(s.view.Messages) && s.cacheLiveRevision == s.liveRevision && s.cacheLivePresent == s.livePresent && s.cacheRows != nil {
209+ rows := s.cacheRows
210+ s.mu.Unlock()
211+ return rows
212+ }
213+ view := s.view.CloneForRender()
214+ messagesRevision, liveRevision := s.view.MessagesRevision, s.liveRevision
215+ s.mu.Unlock()
216+ rows := view.Build(width)
217+ s.mu.Lock()
218+ if s.view.MessagesRevision == messagesRevision && s.liveRevision == liveRevision {
219+ s.cacheWidth, s.cacheMessagesRevision, s.cacheMessageCount, s.cacheLiveRevision, s.cacheLivePresent, s.cacheRows = width, messagesRevision, len(s.view.Messages), liveRevision, s.livePresent, rows
220+ s.view.AdoptRenderCacheFrom(view)
221+ }
222+ s.mu.Unlock()
223+ return rows
224+}
225+
129226 func (s *residentChildSession) View() *tui.View {
130227 if s == nil {
131228 return nil
@@ -200,6 +297,15 @@ func (s *residentChildSession) HandleKey(k tui.Key) (string, bool) {
200297 return prompt, prompt != ""
201298 }
202299
300+func (s *residentChildSession) HandleNavigation(k tui.Key) bool {
301+ if s == nil || s.composer == nil {
302+ return false
303+ }
304+ s.mu.Lock()
305+ defer s.mu.Unlock()
306+ return s.composer.HandleKey(k)
307+}
308+
203309 func (s *residentChildSession) FinishSubmission(err error) {
204310 if s == nil {
205311 return
@@ -245,10 +351,6 @@ func (s *residentChildSession) Render(width, height int) []string {
245351 if s == nil {
246352 return nil
247353 }
248- view := s.View()
249- if view == nil {
250- return []string{" resident subagent unavailable"}
251- }
252354 s.mu.Lock()
253355 loading, loadErr := s.loading, s.err
254356 var editorLines []string
@@ -275,7 +377,7 @@ func (s *residentChildSession) Render(width, height int) []string {
275377 if loadErr != "" {
276378 lines = append(lines, " history: "+loadErr)
277379 }
278- history := view.Build(width)
380+ history := s.renderedHistory(width)
279381 s.mu.Lock()
280382 if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {
281383 s.scrollOffset += len(history) - s.lastRows
@@ -305,7 +407,7 @@ func (s *residentChildSession) Render(width, height int) []string {
305407 }
306408 lines = append(lines, "")
307409 lines = append(lines, editorLines...)
308- lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp: load older history")
410+ lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp/PgDn: scroll history")
309411 if height <= 0 {
310412 return nil
311413 }
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "strings"6 "strings"
6 "sync"7 "sync"
@@ -27,14 +28,31 @@ type residentChildSession struct {
27 // scrollOffset is measured from the rendered live tail. Keeping the28 // scrollOffset is measured from the rendered live tail. Keeping the
28 // viewport bottom-relative preserves the same finalized content when an29 // viewport bottom-relative preserves the same finalized content when an
29 // older page is prepended or the active turn grows.30 // older page is prepended or the active turn grows.
30- scrollOffset int31+ scrollOffset int
31- unread int32+ unread int
32- lastRows int33+ lastRows int
33- recentRows int34+ recentRows int
35+ liveRevision uint64
36+ livePresent bool
37+ cacheWidth int
38+ cacheMessagesRevision uint64
39+ cacheMessageCount int
40+ cacheLiveRevision uint64
41+ cacheLivePresent bool
42+ cacheRows []string
43+ loadCtx context.Context
44+ cancelLoad context.CancelFunc
34 }45 }
35 46
36 func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {47 func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {
37- return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > ")}48+ ctx, cancel := context.WithCancel(context.Background())
49+ return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > "), loadCtx: ctx, cancelLoad: cancel}
50+}
51+
52+func (s *residentChildSession) Close() {
53+ if s != nil && s.cancelLoad != nil {
54+ s.cancelLoad()
55+ }
38 }56 }
39 57
40 func (s *residentChildSession) LoadRecent(limit int) error {58 func (s *residentChildSession) LoadRecent(limit int) error {
@@ -60,6 +78,60 @@ func (s *residentChildSession) ReloadRecent(limit int) error {
60 return s.LoadRecent(limit)78 return s.LoadRecent(limit)
61 }79 }
62 80
81+// LoadAll progressively installs the newest page then prepends every older
82+// page. The callback runs after each durable page so the floating pane grows
83+// naturally while disk history is being reconstructed.
84+func (s *residentChildSession) LoadAll(limit int, progressed func()) error {
85+ if s.loadCtx != nil {
86+ select {
87+ case <-s.loadCtx.Done():
88+ return s.loadCtx.Err()
89+ default:
90+ }
91+ }
92+ if err := s.LoadRecent(limit); err != nil {
93+ return err
94+ }
95+ if progressed != nil {
96+ progressed()
97+ }
98+ for {
99+ if s.loadCtx != nil {
100+ select {
101+ case <-s.loadCtx.Done():
102+ return s.loadCtx.Err()
103+ default:
104+ }
105+ }
106+ s.mu.Lock()
107+ more := s.olderCursor != ""
108+ s.mu.Unlock()
109+ if !more {
110+ return nil
111+ }
112+ if err := s.LoadOlder(limit); err != nil {
113+ return err
114+ }
115+ if progressed != nil {
116+ progressed()
117+ }
118+ }
119+}
120+
121+// ReloadAll rebuilds from one journal generation instead of replacing a
122+// projected-message suffix whose page boundary may not match tool projection.
123+func (s *residentChildSession) ReloadAll(limit int, progressed func()) error {
124+ s.mu.Lock()
125+ s.view.Messages = nil
126+ s.view.MessagesRevision++
127+ s.olderCursor = ""
128+ s.recentRows = 0
129+ // Keep a reader's bottom-relative viewport across a durable reload.
130+ // Render clamps it against the rebuilt rows; a tail follower remains at 0.
131+ s.mu.Unlock()
132+ return s.LoadAll(limit, progressed)
133+}
134+
63 func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {135 func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {
64 if s == nil {136 if s == nil {
65 return137 return
@@ -116,8 +188,12 @@ func (s *residentChildSession) refreshLiveLocked() {
116 s.view.Streaming = ""188 s.view.Streaming = ""
117 s.view.StreamingActive = false189 s.view.StreamingActive = false
118 s.view.ToolCalls = nil190 s.view.ToolCalls = nil
191+ s.liveRevision = 0
192+ s.livePresent = false
119 return193 return
120 }194 }
195+ s.liveRevision = live.Revision
196+ s.livePresent = true
121 s.view.Streaming = live.AssistantText197 s.view.Streaming = live.AssistantText
122 s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""198 s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""
123 s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))199 s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))
@@ -126,6 +202,27 @@ func (s *residentChildSession) refreshLiveLocked() {
126 }202 }
127 }203 }
128 204
205+func (s *residentChildSession) renderedHistory(width int) []string {
206+ s.mu.Lock()
207+ s.refreshLiveLocked()
208+ if s.cacheWidth == width && s.cacheMessagesRevision == s.view.MessagesRevision && s.cacheMessageCount == len(s.view.Messages) && s.cacheLiveRevision == s.liveRevision && s.cacheLivePresent == s.livePresent && s.cacheRows != nil {
209+ rows := s.cacheRows
210+ s.mu.Unlock()
211+ return rows
212+ }
213+ view := s.view.CloneForRender()
214+ messagesRevision, liveRevision := s.view.MessagesRevision, s.liveRevision
215+ s.mu.Unlock()
216+ rows := view.Build(width)
217+ s.mu.Lock()
218+ if s.view.MessagesRevision == messagesRevision && s.liveRevision == liveRevision {
219+ s.cacheWidth, s.cacheMessagesRevision, s.cacheMessageCount, s.cacheLiveRevision, s.cacheLivePresent, s.cacheRows = width, messagesRevision, len(s.view.Messages), liveRevision, s.livePresent, rows
220+ s.view.AdoptRenderCacheFrom(view)
221+ }
222+ s.mu.Unlock()
223+ return rows
224+}
225+
129 func (s *residentChildSession) View() *tui.View {226 func (s *residentChildSession) View() *tui.View {
130 if s == nil {227 if s == nil {
131 return nil228 return nil
@@ -200,6 +297,15 @@ func (s *residentChildSession) HandleKey(k tui.Key) (string, bool) {
200 return prompt, prompt != ""297 return prompt, prompt != ""
201 }298 }
202 299
300+func (s *residentChildSession) HandleNavigation(k tui.Key) bool {
301+ if s == nil || s.composer == nil {
302+ return false
303+ }
304+ s.mu.Lock()
305+ defer s.mu.Unlock()
306+ return s.composer.HandleKey(k)
307+}
308+
203 func (s *residentChildSession) FinishSubmission(err error) {309 func (s *residentChildSession) FinishSubmission(err error) {
204 if s == nil {310 if s == nil {
205 return311 return
@@ -245,10 +351,6 @@ func (s *residentChildSession) Render(width, height int) []string {
245 if s == nil {351 if s == nil {
246 return nil352 return nil
247 }353 }
248- view := s.View()
249- if view == nil {
250- return []string{" resident subagent unavailable"}
251- }
252 s.mu.Lock()354 s.mu.Lock()
253 loading, loadErr := s.loading, s.err355 loading, loadErr := s.loading, s.err
254 var editorLines []string356 var editorLines []string
@@ -275,7 +377,7 @@ func (s *residentChildSession) Render(width, height int) []string {
275 if loadErr != "" {377 if loadErr != "" {
276 lines = append(lines, " history: "+loadErr)378 lines = append(lines, " history: "+loadErr)
277 }379 }
278- history := view.Build(width)380+ history := s.renderedHistory(width)
279 s.mu.Lock()381 s.mu.Lock()
280 if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {382 if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {
281 s.scrollOffset += len(history) - s.lastRows383 s.scrollOffset += len(history) - s.lastRows
@@ -305,7 +407,7 @@ func (s *residentChildSession) Render(width, height int) []string {
305 }407 }
306 lines = append(lines, "")408 lines = append(lines, "")
307 lines = append(lines, editorLines...)409 lines = append(lines, editorLines...)
308- lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp: load older history")410+ lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp/PgDn: scroll history")
309 if height <= 0 {411 if height <= 0 {
310 return nil412 return nil
311 }413 }
M packages/agent/modes/subagent_session_test.go +26 -0
@@ -196,6 +196,32 @@ func TestResidentChildSessionRenderRespectsRequestedHeight(t *testing.T) {
196196 }
197197 }
198198
199+func TestInteractiveResidentChildMouseWheelScrollsHistory(t *testing.T) {
200+ interactive := NewInteractive(InteractiveConfig{Theme: tui.Dark})
201+ session := newResidentChildSession(nil, "child", tui.Dark)
202+ interactive.residentChildSession = session
203+
204+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelUp})
205+ if session.scrollOffset != 1 {
206+ t.Fatalf("wheel up scroll offset = %d, want 1", session.scrollOffset)
207+ }
208+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelDown})
209+ if session.scrollOffset != 0 {
210+ t.Fatalf("wheel down scroll offset = %d, want 0", session.scrollOffset)
211+ }
212+}
213+
214+func TestInteractiveResidentChildMouseReportingFollowsOverlay(t *testing.T) {
215+ term := &alertTestTerminal{}
216+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
217+ interactive.runCtx = context.Background()
218+ interactive.setResidentChildMouseReporting(true)
219+ interactive.setResidentChildMouseReporting(false)
220+ if got := term.String(); got != tui.SeqMouseOn+tui.SeqMouseOff {
221+ t.Fatalf("mouse reporting sequences = %q", got)
222+ }
223+}
224+
199225 func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
200226 root := t.TempDir()
201227 started := make(chan struct{})
@@ -196,6 +196,32 @@ func TestResidentChildSessionRenderRespectsRequestedHeight(t *testing.T) {
196 }196 }
197 }197 }
198 198
199+func TestInteractiveResidentChildMouseWheelScrollsHistory(t *testing.T) {
200+ interactive := NewInteractive(InteractiveConfig{Theme: tui.Dark})
201+ session := newResidentChildSession(nil, "child", tui.Dark)
202+ interactive.residentChildSession = session
203+
204+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelUp})
205+ if session.scrollOffset != 1 {
206+ t.Fatalf("wheel up scroll offset = %d, want 1", session.scrollOffset)
207+ }
208+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelDown})
209+ if session.scrollOffset != 0 {
210+ t.Fatalf("wheel down scroll offset = %d, want 0", session.scrollOffset)
211+ }
212+}
213+
214+func TestInteractiveResidentChildMouseReportingFollowsOverlay(t *testing.T) {
215+ term := &alertTestTerminal{}
216+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
217+ interactive.runCtx = context.Background()
218+ interactive.setResidentChildMouseReporting(true)
219+ interactive.setResidentChildMouseReporting(false)
220+ if got := term.String(); got != tui.SeqMouseOn+tui.SeqMouseOff {
221+ t.Fatalf("mouse reporting sequences = %q", got)
222+ }
223+}
224+
199 func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {225 func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
200 root := t.TempDir()226 root := t.TempDir()
201 started := make(chan struct{})227 started := make(chan struct{})
M packages/agent/modes/subagents_slash.go +6 -1
@@ -98,15 +98,20 @@ func residentResultStatus(id string, result subagents.ResidentResult) string {
9898 func (i *Interactive) openResidentChildSession(childID string) {
9999 session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme)
100100 i.mu.Lock()
101+ previous := i.residentChildSession
101102 if i.residentSubagentsDialog != nil {
102103 i.residentSubagentsDialog.Close()
103104 }
104105 i.residentChildSession = session
105106 i.mu.Unlock()
107+ if previous != nil {
108+ previous.Close()
109+ }
110+ i.setResidentChildMouseReporting(true)
106111 i.invalidate()
107112 if session.BeginLoad() {
108113 go func() {
109- err := session.LoadRecent(200)
114+ err := session.LoadAll(200, i.invalidate)
110115 if session.FinishLoad(err) {
111116 i.reloadResidentChildSession(session)
112117 }
@@ -98,15 +98,20 @@ func residentResultStatus(id string, result subagents.ResidentResult) string {
98 func (i *Interactive) openResidentChildSession(childID string) {98 func (i *Interactive) openResidentChildSession(childID string) {
99 session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme)99 session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme)
100 i.mu.Lock()100 i.mu.Lock()
101+ previous := i.residentChildSession
101 if i.residentSubagentsDialog != nil {102 if i.residentSubagentsDialog != nil {
102 i.residentSubagentsDialog.Close()103 i.residentSubagentsDialog.Close()
103 }104 }
104 i.residentChildSession = session105 i.residentChildSession = session
105 i.mu.Unlock()106 i.mu.Unlock()
107+ if previous != nil {
108+ previous.Close()
109+ }
110+ i.setResidentChildMouseReporting(true)
106 i.invalidate()111 i.invalidate()
107 if session.BeginLoad() {112 if session.BeginLoad() {
108 go func() {113 go func() {
109- err := session.LoadRecent(200)114+ err := session.LoadAll(200, i.invalidate)
110 if session.FinishLoad(err) {115 if session.FinishLoad(err) {
111 i.reloadResidentChildSession(session)116 i.reloadResidentChildSession(session)
112 }117 }
A packages/tui/floating_pane.go +269 -0
@@ -0,0 +1,269 @@
1+package tui
2+
3+import "strings"
4+
5+// FloatingPane is a stateful viewport for an independent TUI view. It keeps
6+// its scroll position while the same view is active, follows a focused row
7+// into view, and composes that view over a live background frame.
8+//
9+// A FloatingPane is deliberately frontend-neutral: callers own their view's
10+// data and key handling, while the pane owns geometry, clipping, and visual
11+// composition.
12+type FloatingPane struct {
13+ id string
14+ scroll int
15+}
16+
17+// FloatingPaneRect describes a pane's outer border in zero-based terminal
18+// coordinates. Content occupies the rectangle inside that border.
19+type FloatingPaneRect struct {
20+ X, Y int
21+ Width, Height int
22+ Drawer bool
23+ Borderless bool
24+}
25+
26+func (r FloatingPaneRect) ContentWidth() int {
27+ if r.Drawer || r.Borderless {
28+ return max(1, r.Width)
29+ }
30+ return max(1, r.Width-2)
31+}
32+
33+func (r FloatingPaneRect) ContentHeight() int {
34+ if r.Borderless {
35+ return max(1, r.Height)
36+ }
37+ if r.Drawer {
38+ return max(1, r.Height-1)
39+ }
40+ return max(1, r.Height-2)
41+}
42+
43+func (r FloatingPaneRect) contentX() int {
44+ if r.Drawer || r.Borderless {
45+ return r.X
46+ }
47+ return r.X + 1
48+}
49+
50+func (r FloatingPaneRect) contentY() int {
51+ if r.Borderless {
52+ return r.Y
53+ }
54+ return r.Y + 1
55+}
56+
57+// FloatingPaneMaxRect returns the largest pane for the current terminal. The
58+// final pane is shortened to its content by Compose. Below 80 columns it
59+// becomes a full-width bottom drawer so dialogs remain readable and usable.
60+func FloatingPaneMaxRect(cols, rows int) FloatingPaneRect {
61+ if cols < 1 || rows < 1 {
62+ return FloatingPaneRect{}
63+ }
64+ height := max(1, rows*4/5)
65+ if rows >= 5 {
66+ height = max(5, height)
67+ }
68+ if height > rows {
69+ height = rows
70+ }
71+ if cols < 80 {
72+ return FloatingPaneRect{Y: rows - height, Width: cols, Height: height, Drawer: true}
73+ }
74+ width := max(6, cols*4/5)
75+ if width > cols {
76+ width = cols
77+ }
78+ return FloatingPaneRect{
79+ X: (cols - width) / 2,
80+ Y: (rows - height) / 2,
81+ Width: width,
82+ Height: height,
83+ }
84+}
85+
86+// Reset discards the viewport position. Callers normally do not need this:
87+// Compose resets it automatically when the view identity changes.
88+func (p *FloatingPane) Reset() {
89+ if p == nil {
90+ return
91+ }
92+ p.id = ""
93+ p.scroll = 0
94+}
95+
96+// FloatingPaneFrame contains the dimmed background and foreground rows to
97+// paint at absolute terminal positions. Renderer.DrawFloating consumes it.
98+type FloatingPaneFrame struct {
99+ Background []string
100+ Lines []string
101+ Rect FloatingPaneRect
102+}
103+
104+// Compose returns a complete floating-pane frame. title is centered in the
105+// pane border. contentCursorRow is relative to content and is used both to
106+// retain the pane's own viewport and to return an absolute terminal cursor
107+// row. Pass -1 when the foreground has no text cursor.
108+func (p *FloatingPane) Compose(theme Theme, id, title string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) {
109+ pane.Background = DimLines(fitFloatingBackground(background, cols, rows))
110+ if cols < 1 || rows < 1 {
111+ return pane, -1, 0
112+ }
113+ if p == nil {
114+ p = &FloatingPane{}
115+ }
116+ if p.id != id {
117+ p.id = id
118+ p.scroll = 0
119+ }
120+
121+ pane.Rect = FloatingPaneMaxRect(cols, rows)
122+ chromeRows := 2
123+ if pane.Rect.Drawer {
124+ chromeRows = 1
125+ }
126+ if len(content)+chromeRows < pane.Rect.Height {
127+ pane.Rect.Height = max(1, len(content)+chromeRows)
128+ if pane.Rect.Drawer {
129+ pane.Rect.Y = rows - pane.Rect.Height
130+ } else {
131+ pane.Rect.Y = (rows - pane.Rect.Height) / 2
132+ }
133+ }
134+ tooSmall := pane.Rect.Width < 3 || (!pane.Rect.Drawer && pane.Rect.Height < 3) || (pane.Rect.Drawer && pane.Rect.Height < 2)
135+ if tooSmall {
136+ // There is no room for a reliable border. Keep the foreground usable
137+ // on a tiny terminal by treating the entire screen as its viewport.
138+ pane.Rect = FloatingPaneRect{Width: cols, Height: rows, Borderless: true}
139+ }
140+
141+ viewportRows := pane.Rect.ContentHeight()
142+ p.follow(contentCursorRow, len(content), viewportRows)
143+ content = p.viewport(content, viewportRows)
144+ pane.Lines = floatingPaneLines(theme, pane.Rect, title, content)
145+
146+ if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows {
147+ cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll
148+ cursorCol = pane.Rect.contentX() + max(0, contentCursorCol)
149+ cursorCol = min(cursorCol, pane.Rect.contentX()+pane.Rect.ContentWidth()-1)
150+ cursorCol = min(cursorCol, cols-1)
151+ return pane, cursorRow, cursorCol
152+ }
153+ return pane, -1, 0
154+}
155+
156+func (p *FloatingPane) follow(focusRow, contentRows, viewportRows int) {
157+ if focusRow < 0 || viewportRows < 1 {
158+ p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows)
159+ return
160+ }
161+ if focusRow < p.scroll {
162+ p.scroll = focusRow
163+ } else if focusRow >= p.scroll+viewportRows {
164+ p.scroll = focusRow - viewportRows + 1
165+ }
166+ p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows)
167+}
168+
169+func (p *FloatingPane) viewport(content []string, rows int) []string {
170+ if rows < 1 {
171+ return nil
172+ }
173+ p.scroll = clampFloatingScroll(p.scroll, len(content), rows)
174+ end := min(len(content), p.scroll+rows)
175+ return content[p.scroll:end]
176+}
177+
178+func clampFloatingScroll(scroll, contentRows, viewportRows int) int {
179+ if scroll < 0 {
180+ return 0
181+ }
182+ if maxScroll := max(0, contentRows-viewportRows); scroll > maxScroll {
183+ return maxScroll
184+ }
185+ return scroll
186+}
187+
188+func fitFloatingBackground(background []string, cols, rows int) []string {
189+ frame := make([]string, rows)
190+ if cols < 1 || rows < 1 {
191+ return frame
192+ }
193+ if len(background) > rows {
194+ background = background[len(background)-rows:]
195+ }
196+ start := rows - len(background)
197+ for row, line := range background {
198+ frame[start+row] = truncateToWidth(line, cols)
199+ }
200+ return frame
201+}
202+
203+func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string {
204+ if rect.Width < 1 || rect.Height < 1 {
205+ return nil
206+ }
207+ tooSmall := rect.Width < 3 || (!rect.Drawer && rect.Height < 3) || (rect.Drawer && rect.Height < 2)
208+ if tooSmall {
209+ lines := make([]string, rect.Height)
210+ copy(lines, content)
211+ return lines
212+ }
213+ lines := make([]string, rect.Height)
214+ if rect.Drawer {
215+ lines[0] = floatingPaneTitle(theme, title, rect.Width, false)
216+ for row := 0; row < rect.ContentHeight(); row++ {
217+ line := ""
218+ if row < len(content) {
219+ line = truncateToWidth(content[row], rect.ContentWidth())
220+ }
221+ if width := visibleWidth(line); width < rect.ContentWidth() {
222+ line += strings.Repeat(" ", rect.ContentWidth()-width)
223+ }
224+ lines[row+1] = line
225+ }
226+ return lines
227+ }
228+ border := theme.FGColor(theme.Muted, "│")
229+ lines[0] = floatingPaneTitle(theme, title, rect.Width, true)
230+ lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯")
231+ for row := 0; row < rect.ContentHeight(); row++ {
232+ line := ""
233+ if row < len(content) {
234+ line = truncateToWidth(content[row], rect.ContentWidth())
235+ }
236+ if width := visibleWidth(line); width < rect.ContentWidth() {
237+ line += strings.Repeat(" ", rect.ContentWidth()-width)
238+ }
239+ lines[row+1] = border + line + border
240+ }
241+ return lines
242+}
243+
244+func floatingPaneTitle(theme Theme, title string, width int, boxed bool) string {
245+ if width < 1 {
246+ return ""
247+ }
248+ interior := width
249+ left, right := "", ""
250+ if boxed {
251+ if width < 3 {
252+ return theme.FGColor(theme.Muted, strings.Repeat("─", width))
253+ }
254+ interior -= 2
255+ left, right = "╭", "╮"
256+ }
257+ label := ""
258+ if title != "" {
259+ label = " " + truncateToWidth(title, max(1, interior-2)) + " "
260+ }
261+ labelWidth := visibleWidth(label)
262+ if labelWidth > interior {
263+ label = truncateToWidth(label, interior)
264+ labelWidth = visibleWidth(label)
265+ }
266+ before := max(0, (interior-labelWidth)/2)
267+ after := max(0, interior-labelWidth-before)
268+ return theme.FGColor(theme.Muted, left+strings.Repeat("─", before)+label+strings.Repeat("─", after)+right)
269+}
@@ -0,0 +1,269 @@
1+package tui
2+
3+import "strings"
4+
5+// FloatingPane is a stateful viewport for an independent TUI view. It keeps
6+// its scroll position while the same view is active, follows a focused row
7+// into view, and composes that view over a live background frame.
8+//
9+// A FloatingPane is deliberately frontend-neutral: callers own their view's
10+// data and key handling, while the pane owns geometry, clipping, and visual
11+// composition.
12+type FloatingPane struct {
13+ id string
14+ scroll int
15+}
16+
17+// FloatingPaneRect describes a pane's outer border in zero-based terminal
18+// coordinates. Content occupies the rectangle inside that border.
19+type FloatingPaneRect struct {
20+ X, Y int
21+ Width, Height int
22+ Drawer bool
23+ Borderless bool
24+}
25+
26+func (r FloatingPaneRect) ContentWidth() int {
27+ if r.Drawer || r.Borderless {
28+ return max(1, r.Width)
29+ }
30+ return max(1, r.Width-2)
31+}
32+
33+func (r FloatingPaneRect) ContentHeight() int {
34+ if r.Borderless {
35+ return max(1, r.Height)
36+ }
37+ if r.Drawer {
38+ return max(1, r.Height-1)
39+ }
40+ return max(1, r.Height-2)
41+}
42+
43+func (r FloatingPaneRect) contentX() int {
44+ if r.Drawer || r.Borderless {
45+ return r.X
46+ }
47+ return r.X + 1
48+}
49+
50+func (r FloatingPaneRect) contentY() int {
51+ if r.Borderless {
52+ return r.Y
53+ }
54+ return r.Y + 1
55+}
56+
57+// FloatingPaneMaxRect returns the largest pane for the current terminal. The
58+// final pane is shortened to its content by Compose. Below 80 columns it
59+// becomes a full-width bottom drawer so dialogs remain readable and usable.
60+func FloatingPaneMaxRect(cols, rows int) FloatingPaneRect {
61+ if cols < 1 || rows < 1 {
62+ return FloatingPaneRect{}
63+ }
64+ height := max(1, rows*4/5)
65+ if rows >= 5 {
66+ height = max(5, height)
67+ }
68+ if height > rows {
69+ height = rows
70+ }
71+ if cols < 80 {
72+ return FloatingPaneRect{Y: rows - height, Width: cols, Height: height, Drawer: true}
73+ }
74+ width := max(6, cols*4/5)
75+ if width > cols {
76+ width = cols
77+ }
78+ return FloatingPaneRect{
79+ X: (cols - width) / 2,
80+ Y: (rows - height) / 2,
81+ Width: width,
82+ Height: height,
83+ }
84+}
85+
86+// Reset discards the viewport position. Callers normally do not need this:
87+// Compose resets it automatically when the view identity changes.
88+func (p *FloatingPane) Reset() {
89+ if p == nil {
90+ return
91+ }
92+ p.id = ""
93+ p.scroll = 0
94+}
95+
96+// FloatingPaneFrame contains the dimmed background and foreground rows to
97+// paint at absolute terminal positions. Renderer.DrawFloating consumes it.
98+type FloatingPaneFrame struct {
99+ Background []string
100+ Lines []string
101+ Rect FloatingPaneRect
102+}
103+
104+// Compose returns a complete floating-pane frame. title is centered in the
105+// pane border. contentCursorRow is relative to content and is used both to
106+// retain the pane's own viewport and to return an absolute terminal cursor
107+// row. Pass -1 when the foreground has no text cursor.
108+func (p *FloatingPane) Compose(theme Theme, id, title string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) {
109+ pane.Background = DimLines(fitFloatingBackground(background, cols, rows))
110+ if cols < 1 || rows < 1 {
111+ return pane, -1, 0
112+ }
113+ if p == nil {
114+ p = &FloatingPane{}
115+ }
116+ if p.id != id {
117+ p.id = id
118+ p.scroll = 0
119+ }
120+
121+ pane.Rect = FloatingPaneMaxRect(cols, rows)
122+ chromeRows := 2
123+ if pane.Rect.Drawer {
124+ chromeRows = 1
125+ }
126+ if len(content)+chromeRows < pane.Rect.Height {
127+ pane.Rect.Height = max(1, len(content)+chromeRows)
128+ if pane.Rect.Drawer {
129+ pane.Rect.Y = rows - pane.Rect.Height
130+ } else {
131+ pane.Rect.Y = (rows - pane.Rect.Height) / 2
132+ }
133+ }
134+ tooSmall := pane.Rect.Width < 3 || (!pane.Rect.Drawer && pane.Rect.Height < 3) || (pane.Rect.Drawer && pane.Rect.Height < 2)
135+ if tooSmall {
136+ // There is no room for a reliable border. Keep the foreground usable
137+ // on a tiny terminal by treating the entire screen as its viewport.
138+ pane.Rect = FloatingPaneRect{Width: cols, Height: rows, Borderless: true}
139+ }
140+
141+ viewportRows := pane.Rect.ContentHeight()
142+ p.follow(contentCursorRow, len(content), viewportRows)
143+ content = p.viewport(content, viewportRows)
144+ pane.Lines = floatingPaneLines(theme, pane.Rect, title, content)
145+
146+ if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows {
147+ cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll
148+ cursorCol = pane.Rect.contentX() + max(0, contentCursorCol)
149+ cursorCol = min(cursorCol, pane.Rect.contentX()+pane.Rect.ContentWidth()-1)
150+ cursorCol = min(cursorCol, cols-1)
151+ return pane, cursorRow, cursorCol
152+ }
153+ return pane, -1, 0
154+}
155+
156+func (p *FloatingPane) follow(focusRow, contentRows, viewportRows int) {
157+ if focusRow < 0 || viewportRows < 1 {
158+ p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows)
159+ return
160+ }
161+ if focusRow < p.scroll {
162+ p.scroll = focusRow
163+ } else if focusRow >= p.scroll+viewportRows {
164+ p.scroll = focusRow - viewportRows + 1
165+ }
166+ p.scroll = clampFloatingScroll(p.scroll, contentRows, viewportRows)
167+}
168+
169+func (p *FloatingPane) viewport(content []string, rows int) []string {
170+ if rows < 1 {
171+ return nil
172+ }
173+ p.scroll = clampFloatingScroll(p.scroll, len(content), rows)
174+ end := min(len(content), p.scroll+rows)
175+ return content[p.scroll:end]
176+}
177+
178+func clampFloatingScroll(scroll, contentRows, viewportRows int) int {
179+ if scroll < 0 {
180+ return 0
181+ }
182+ if maxScroll := max(0, contentRows-viewportRows); scroll > maxScroll {
183+ return maxScroll
184+ }
185+ return scroll
186+}
187+
188+func fitFloatingBackground(background []string, cols, rows int) []string {
189+ frame := make([]string, rows)
190+ if cols < 1 || rows < 1 {
191+ return frame
192+ }
193+ if len(background) > rows {
194+ background = background[len(background)-rows:]
195+ }
196+ start := rows - len(background)
197+ for row, line := range background {
198+ frame[start+row] = truncateToWidth(line, cols)
199+ }
200+ return frame
201+}
202+
203+func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string {
204+ if rect.Width < 1 || rect.Height < 1 {
205+ return nil
206+ }
207+ tooSmall := rect.Width < 3 || (!rect.Drawer && rect.Height < 3) || (rect.Drawer && rect.Height < 2)
208+ if tooSmall {
209+ lines := make([]string, rect.Height)
210+ copy(lines, content)
211+ return lines
212+ }
213+ lines := make([]string, rect.Height)
214+ if rect.Drawer {
215+ lines[0] = floatingPaneTitle(theme, title, rect.Width, false)
216+ for row := 0; row < rect.ContentHeight(); row++ {
217+ line := ""
218+ if row < len(content) {
219+ line = truncateToWidth(content[row], rect.ContentWidth())
220+ }
221+ if width := visibleWidth(line); width < rect.ContentWidth() {
222+ line += strings.Repeat(" ", rect.ContentWidth()-width)
223+ }
224+ lines[row+1] = line
225+ }
226+ return lines
227+ }
228+ border := theme.FGColor(theme.Muted, "│")
229+ lines[0] = floatingPaneTitle(theme, title, rect.Width, true)
230+ lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯")
231+ for row := 0; row < rect.ContentHeight(); row++ {
232+ line := ""
233+ if row < len(content) {
234+ line = truncateToWidth(content[row], rect.ContentWidth())
235+ }
236+ if width := visibleWidth(line); width < rect.ContentWidth() {
237+ line += strings.Repeat(" ", rect.ContentWidth()-width)
238+ }
239+ lines[row+1] = border + line + border
240+ }
241+ return lines
242+}
243+
244+func floatingPaneTitle(theme Theme, title string, width int, boxed bool) string {
245+ if width < 1 {
246+ return ""
247+ }
248+ interior := width
249+ left, right := "", ""
250+ if boxed {
251+ if width < 3 {
252+ return theme.FGColor(theme.Muted, strings.Repeat("─", width))
253+ }
254+ interior -= 2
255+ left, right = "╭", "╮"
256+ }
257+ label := ""
258+ if title != "" {
259+ label = " " + truncateToWidth(title, max(1, interior-2)) + " "
260+ }
261+ labelWidth := visibleWidth(label)
262+ if labelWidth > interior {
263+ label = truncateToWidth(label, interior)
264+ labelWidth = visibleWidth(label)
265+ }
266+ before := max(0, (interior-labelWidth)/2)
267+ after := max(0, interior-labelWidth-before)
268+ return theme.FGColor(theme.Muted, left+strings.Repeat("─", before)+label+strings.Repeat("─", after)+right)
269+}
A packages/tui/floating_pane_test.go +150 -0
@@ -0,0 +1,150 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {
9+ var pane FloatingPane
10+ frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0)
11+
12+ if frame.Rect.Drawer {
13+ t.Fatal("100-column terminal unexpectedly used drawer layout")
14+ }
15+ if want := (100 - frame.Rect.Width) / 2; frame.Rect.X != want {
16+ t.Fatalf("pane x = %d, want %d", frame.Rect.X, want)
17+ }
18+ if !strings.Contains(frame.Background[len(frame.Background)-2], Dim("live transcript")) {
19+ t.Fatalf("live background was not dimmed: %q", frame.Background)
20+ }
21+ if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") {
22+ t.Fatalf("pane is missing top border: %q", got)
23+ } else if title := strings.Index(got, "settings"); title < 0 {
24+ t.Fatalf("pane title is missing from its border: %q", got)
25+ } else {
26+ left := strings.Count(got[:title], "─")
27+ right := strings.Count(got[title+len("settings"):], "─")
28+ if left-right > 1 || right-left > 1 {
29+ t.Fatalf("pane title is not centered in its border: %q", got)
30+ }
31+ }
32+ if cursorRow != -1 || cursorCol != 0 {
33+ t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol)
34+ }
35+}
36+
37+func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) {
38+ var pane FloatingPane
39+ frame, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"settings body"}, 79, 24, -1, 0)
40+
41+ if !frame.Rect.Drawer {
42+ t.Fatal("79-column terminal did not use drawer layout")
43+ }
44+ if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 {
45+ t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect)
46+ }
47+ if got := stripANSI(frame.Lines[0]); !strings.Contains(got, "settings") {
48+ t.Fatalf("drawer title = %q, want centered settings title", got)
49+ }
50+}
51+
52+func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) {
53+ var pane FloatingPane
54+ wide, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 100, 30, -1, 0)
55+ narrow, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 79, 24, -1, 0)
56+
57+ if wide.Rect.Drawer {
58+ t.Fatal("wide terminal unexpectedly used a drawer")
59+ }
60+ if !narrow.Rect.Drawer || narrow.Rect.Width != 79 || narrow.Rect.Y+narrow.Rect.Height != 24 {
61+ t.Fatalf("resize did not recompute a bottom drawer: %+v", narrow.Rect)
62+ }
63+}
64+
65+func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) {
66+ var pane FloatingPane
67+ frame, _, col := pane.Compose(Dark, "search", "search", nil, []string{"query"}, 100, 20, 0, 1000)
68+ if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want {
69+ t.Fatalf("cursor col = %d, want content edge %d", col, want)
70+ }
71+
72+ tiny, row, tinyCol := pane.Compose(Dark, "search", "search", nil, []string{"x"}, 2, 2, 0, 10)
73+ if !tiny.Rect.Borderless {
74+ t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect)
75+ }
76+ if row != 0 || tinyCol != 1 {
77+ t.Fatalf("tiny cursor = (%d, %d), want (0, 1)", row, tinyCol)
78+ }
79+}
80+
81+func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.T) {
82+ content := make([]string, 30)
83+ for row := range content {
84+ content[row] = "row " + itoa(row)
85+ }
86+ var pane FloatingPane
87+ frame, cursorRow, _ := pane.Compose(Dark, "settings", "settings", nil, content, 100, 12, 25, 0)
88+ if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 {
89+ t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect)
90+ }
91+ if !strings.Contains(stripANSI(strings.Join(frame.Lines, "\n")), "row 25") {
92+ t.Fatal("focused row was not retained in the pane viewport")
93+ }
94+
95+ other, _, _ := pane.Compose(Dark, "subagents", "subagents", nil, content, 100, 12, -1, 0)
96+ if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") {
97+ t.Fatal("new view inherited the previous view's scroll position")
98+ }
99+}
100+
101+func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testing.T) {
102+ var out strings.Builder
103+ r := NewRenderer(&out)
104+ r.Resize(100, 20)
105+ var pane FloatingPane
106+ frame, _, _ := pane.Compose(Dark, "settings", "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0)
107+ r.DrawFloating(frame, -1, 0)
108+ got := out.String()
109+ if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") {
110+ t.Fatalf("floating render omitted a layer: %q", got)
111+ }
112+ if !strings.Contains(got, MoveTo(frame.Rect.Y+1, frame.Rect.X+1)) {
113+ t.Fatalf("floating pane was not painted at its geometry: %q", got)
114+ }
115+
116+ out.Reset()
117+ r.Draw(frame.Background, -1, 0)
118+ if !strings.Contains(out.String(), MoveTo(1, 1)) {
119+ t.Fatalf("normal frame did not repaint after a floating pane: %q", out.String())
120+ }
121+}
122+
123+func TestRendererDrawFloatingPreservesThemeBackgroundAndImageInvalidation(t *testing.T) {
124+ var out strings.Builder
125+ r := NewRenderer(&out)
126+ background := Color256(237)
127+ th := Dark
128+ th.Background = &background
129+ r.SetTheme(th)
130+ r.Resize(20, 4)
131+
132+ pane := FloatingPaneFrame{
133+ Background: make([]string, 4),
134+ Lines: []string{"\x1b_Ga=T,f=100;image\x1b\\"},
135+ Rect: FloatingPaneRect{X: 2, Y: 1, Width: 10, Height: 1, Borderless: true},
136+ }
137+ r.DrawFloating(pane, -1, 0)
138+ if !strings.Contains(out.String(), th.BackgroundStyle()) {
139+ t.Fatalf("floating pane omitted theme background: %q", out.String())
140+ }
141+ if !r.prevHadImage || !r.prevHadKittyImage {
142+ t.Fatalf("floating image state = image:%t kitty:%t, want both true", r.prevHadImage, r.prevHadKittyImage)
143+ }
144+
145+ out.Reset()
146+ r.Draw([]string{""}, -1, 0)
147+ if !strings.Contains(out.String(), SeqDeleteKittyImages) {
148+ t.Fatalf("stale kitty image was not deleted: %q", out.String())
149+ }
150+}
@@ -0,0 +1,150 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {
9+ var pane FloatingPane
10+ frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0)
11+
12+ if frame.Rect.Drawer {
13+ t.Fatal("100-column terminal unexpectedly used drawer layout")
14+ }
15+ if want := (100 - frame.Rect.Width) / 2; frame.Rect.X != want {
16+ t.Fatalf("pane x = %d, want %d", frame.Rect.X, want)
17+ }
18+ if !strings.Contains(frame.Background[len(frame.Background)-2], Dim("live transcript")) {
19+ t.Fatalf("live background was not dimmed: %q", frame.Background)
20+ }
21+ if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") {
22+ t.Fatalf("pane is missing top border: %q", got)
23+ } else if title := strings.Index(got, "settings"); title < 0 {
24+ t.Fatalf("pane title is missing from its border: %q", got)
25+ } else {
26+ left := strings.Count(got[:title], "─")
27+ right := strings.Count(got[title+len("settings"):], "─")
28+ if left-right > 1 || right-left > 1 {
29+ t.Fatalf("pane title is not centered in its border: %q", got)
30+ }
31+ }
32+ if cursorRow != -1 || cursorCol != 0 {
33+ t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol)
34+ }
35+}
36+
37+func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) {
38+ var pane FloatingPane
39+ frame, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"settings body"}, 79, 24, -1, 0)
40+
41+ if !frame.Rect.Drawer {
42+ t.Fatal("79-column terminal did not use drawer layout")
43+ }
44+ if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 {
45+ t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect)
46+ }
47+ if got := stripANSI(frame.Lines[0]); !strings.Contains(got, "settings") {
48+ t.Fatalf("drawer title = %q, want centered settings title", got)
49+ }
50+}
51+
52+func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) {
53+ var pane FloatingPane
54+ wide, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 100, 30, -1, 0)
55+ narrow, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 79, 24, -1, 0)
56+
57+ if wide.Rect.Drawer {
58+ t.Fatal("wide terminal unexpectedly used a drawer")
59+ }
60+ if !narrow.Rect.Drawer || narrow.Rect.Width != 79 || narrow.Rect.Y+narrow.Rect.Height != 24 {
61+ t.Fatalf("resize did not recompute a bottom drawer: %+v", narrow.Rect)
62+ }
63+}
64+
65+func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) {
66+ var pane FloatingPane
67+ frame, _, col := pane.Compose(Dark, "search", "search", nil, []string{"query"}, 100, 20, 0, 1000)
68+ if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want {
69+ t.Fatalf("cursor col = %d, want content edge %d", col, want)
70+ }
71+
72+ tiny, row, tinyCol := pane.Compose(Dark, "search", "search", nil, []string{"x"}, 2, 2, 0, 10)
73+ if !tiny.Rect.Borderless {
74+ t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect)
75+ }
76+ if row != 0 || tinyCol != 1 {
77+ t.Fatalf("tiny cursor = (%d, %d), want (0, 1)", row, tinyCol)
78+ }
79+}
80+
81+func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.T) {
82+ content := make([]string, 30)
83+ for row := range content {
84+ content[row] = "row " + itoa(row)
85+ }
86+ var pane FloatingPane
87+ frame, cursorRow, _ := pane.Compose(Dark, "settings", "settings", nil, content, 100, 12, 25, 0)
88+ if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 {
89+ t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect)
90+ }
91+ if !strings.Contains(stripANSI(strings.Join(frame.Lines, "\n")), "row 25") {
92+ t.Fatal("focused row was not retained in the pane viewport")
93+ }
94+
95+ other, _, _ := pane.Compose(Dark, "subagents", "subagents", nil, content, 100, 12, -1, 0)
96+ if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") {
97+ t.Fatal("new view inherited the previous view's scroll position")
98+ }
99+}
100+
101+func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testing.T) {
102+ var out strings.Builder
103+ r := NewRenderer(&out)
104+ r.Resize(100, 20)
105+ var pane FloatingPane
106+ frame, _, _ := pane.Compose(Dark, "settings", "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0)
107+ r.DrawFloating(frame, -1, 0)
108+ got := out.String()
109+ if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") {
110+ t.Fatalf("floating render omitted a layer: %q", got)
111+ }
112+ if !strings.Contains(got, MoveTo(frame.Rect.Y+1, frame.Rect.X+1)) {
113+ t.Fatalf("floating pane was not painted at its geometry: %q", got)
114+ }
115+
116+ out.Reset()
117+ r.Draw(frame.Background, -1, 0)
118+ if !strings.Contains(out.String(), MoveTo(1, 1)) {
119+ t.Fatalf("normal frame did not repaint after a floating pane: %q", out.String())
120+ }
121+}
122+
123+func TestRendererDrawFloatingPreservesThemeBackgroundAndImageInvalidation(t *testing.T) {
124+ var out strings.Builder
125+ r := NewRenderer(&out)
126+ background := Color256(237)
127+ th := Dark
128+ th.Background = &background
129+ r.SetTheme(th)
130+ r.Resize(20, 4)
131+
132+ pane := FloatingPaneFrame{
133+ Background: make([]string, 4),
134+ Lines: []string{"\x1b_Ga=T,f=100;image\x1b\\"},
135+ Rect: FloatingPaneRect{X: 2, Y: 1, Width: 10, Height: 1, Borderless: true},
136+ }
137+ r.DrawFloating(pane, -1, 0)
138+ if !strings.Contains(out.String(), th.BackgroundStyle()) {
139+ t.Fatalf("floating pane omitted theme background: %q", out.String())
140+ }
141+ if !r.prevHadImage || !r.prevHadKittyImage {
142+ t.Fatalf("floating image state = image:%t kitty:%t, want both true", r.prevHadImage, r.prevHadKittyImage)
143+ }
144+
145+ out.Reset()
146+ r.Draw([]string{""}, -1, 0)
147+ if !strings.Contains(out.String(), SeqDeleteKittyImages) {
148+ t.Fatalf("stale kitty image was not deleted: %q", out.String())
149+ }
150+}
M packages/tui/render.go +73 -8
@@ -27,9 +27,13 @@ type Renderer struct {
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
34+ floatingActive bool
35+ drawingFloating bool
36+ floatingRect FloatingPaneRect
3337
3438 // Main-screen flow renderer state. logLines is the full logical
3539 // buffer (chat + live bottom band) from the previous DrawLog call.
@@ -415,6 +419,10 @@ func truncateToWidth(s string, cols int) string {
415419 }
416420
417421 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
422+ if r.floatingActive && !r.drawingFloating {
423+ r.Invalidate()
424+ r.floatingActive = false
425+ }
418426 if r.cols == 0 || r.rows == 0 {
419427 return
420428 }
@@ -468,11 +476,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
468476 // its scrollbar to the top of the viewport on every image or
469477 // selection-highlight frame.
470478 w.WriteString(SeqClearScreenNoHome)
471- if curHasKittyImage {
472- // Delete previously placed kitty images once per frame,
473- // before rewriting all rows. Doing this inside each image
474- // escape makes only the last image in the frame survive.
475- w.WriteString("\x1b_Ga=d\x1b\\")
479+ if curHasKittyImage || r.prevHadKittyImage {
480+ // Delete previously placed kitty images once per frame, before
481+ // rewriting all rows. This also removes a former foreground image
482+ // when the replacement frame contains no Kitty image escape.
483+ w.WriteString(SeqDeleteKittyImages)
476484 }
477485 }
478486
@@ -523,10 +531,67 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
523531
524532 r.prev = frame
525533 r.prevHadImage = curHasImage
534+ r.prevHadKittyImage = curHasKittyImage
526535 r.cursorRow = cursorRow
527536 r.cursorCol = cursorCol
528537 }
529538
539+// DrawFloating paints a complete, dimmed live background and then an opaque
540+// floating pane at absolute coordinates. The background is deliberately
541+// invalidated on every call: the pane is not represented in Draw's frame cache,
542+// so repainting it first prevents stale foreground cells when either layer
543+// changes or the terminal is resized.
544+func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int) {
545+ if r.cols == 0 || r.rows == 0 {
546+ return
547+ }
548+ if !r.floatingActive || r.floatingRect != pane.Rect {
549+ r.Invalidate()
550+ }
551+ r.drawingFloating = true
552+ r.Draw(pane.Background, -1, 0)
553+ r.drawingFloating = false
554+
555+ paneHasImage := false
556+ paneHasKittyImage := false
557+ for _, line := range pane.Lines {
558+ if containsImageEscape(line) {
559+ paneHasImage = true
560+ if strings.Contains(line, "\x1b_G") {
561+ paneHasKittyImage = true
562+ }
563+ }
564+ }
565+
566+ var w strings.Builder
567+ w.WriteString(SeqSynchronizedOn)
568+ w.WriteString(SeqHideCursor)
569+ for row, line := range pane.Lines {
570+ y := pane.Rect.Y + row
571+ if y < 0 || y >= r.rows {
572+ continue
573+ }
574+ if pane.Rect.X >= r.cols {
575+ continue
576+ }
577+ width := min(pane.Rect.Width, r.cols-pane.Rect.X)
578+ w.WriteString(MoveTo(y+1, pane.Rect.X+1))
579+ w.WriteString("\x1b[0m")
580+ w.WriteString(paintBackgroundRow(truncateToWidth(line, width), width, r.backgroundStyle))
581+ }
582+ if cursorRow >= 0 && cursorRow < r.rows && cursorCol >= 0 && cursorCol < r.cols {
583+ w.WriteString(MoveTo(cursorRow+1, cursorCol+1))
584+ w.WriteString(SeqShowCursor)
585+ }
586+ w.WriteString(SeqSynchronizedOff)
587+ _, _ = io.WriteString(r.out, w.String())
588+ r.prevHadImage = r.prevHadImage || paneHasImage
589+ r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage
590+ // Keep the dimmed background cache while this geometry remains active.
591+ r.floatingActive = true
592+ r.floatingRect = pane.Rect
593+}
594+
530595 // DrawLog renders zut in the terminal's main screen as normal terminal
531596 // flow rather than a fixed full-screen frame. Chat lines are emitted once
532597 // into the host terminal scrollback; the current bottom block (dialogs,
@@ -27,9 +27,13 @@ type Renderer struct {
27 27
28 // prevHadImage tracks whether the previous frame contained 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
34+ floatingActive bool
35+ drawingFloating bool
36+ floatingRect FloatingPaneRect
33 37
34 // Main-screen flow renderer state. logLines is the full logical38 // Main-screen flow renderer state. logLines is the full logical
35 // buffer (chat + live bottom band) from the previous DrawLog call.39 // buffer (chat + live bottom band) from the previous DrawLog call.
@@ -415,6 +419,10 @@ func truncateToWidth(s string, cols int) string {
415 }419 }
416 420
417 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {421 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
422+ if r.floatingActive && !r.drawingFloating {
423+ r.Invalidate()
424+ r.floatingActive = false
425+ }
418 if r.cols == 0 || r.rows == 0 {426 if r.cols == 0 || r.rows == 0 {
419 return427 return
420 }428 }
@@ -468,11 +476,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
468 // its scrollbar to the top of the viewport on every image or476 // its scrollbar to the top of the viewport on every image or
469 // selection-highlight frame.477 // selection-highlight frame.
470 w.WriteString(SeqClearScreenNoHome)478 w.WriteString(SeqClearScreenNoHome)
471- if curHasKittyImage {479+ if curHasKittyImage || r.prevHadKittyImage {
472- // Delete previously placed kitty images once per frame,480+ // Delete previously placed kitty images once per frame, before
473- // before rewriting all rows. Doing this inside each image481+ // rewriting all rows. This also removes a former foreground image
474- // escape makes only the last image in the frame survive.482+ // when the replacement frame contains no Kitty image escape.
475- w.WriteString("\x1b_Ga=d\x1b\\")483+ w.WriteString(SeqDeleteKittyImages)
476 }484 }
477 }485 }
478 486
@@ -523,10 +531,67 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
523 531
524 r.prev = frame532 r.prev = frame
525 r.prevHadImage = curHasImage533 r.prevHadImage = curHasImage
534+ r.prevHadKittyImage = curHasKittyImage
526 r.cursorRow = cursorRow535 r.cursorRow = cursorRow
527 r.cursorCol = cursorCol536 r.cursorCol = cursorCol
528 }537 }
529 538
539+// DrawFloating paints a complete, dimmed live background and then an opaque
540+// floating pane at absolute coordinates. The background is deliberately
541+// invalidated on every call: the pane is not represented in Draw's frame cache,
542+// so repainting it first prevents stale foreground cells when either layer
543+// changes or the terminal is resized.
544+func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int) {
545+ if r.cols == 0 || r.rows == 0 {
546+ return
547+ }
548+ if !r.floatingActive || r.floatingRect != pane.Rect {
549+ r.Invalidate()
550+ }
551+ r.drawingFloating = true
552+ r.Draw(pane.Background, -1, 0)
553+ r.drawingFloating = false
554+
555+ paneHasImage := false
556+ paneHasKittyImage := false
557+ for _, line := range pane.Lines {
558+ if containsImageEscape(line) {
559+ paneHasImage = true
560+ if strings.Contains(line, "\x1b_G") {
561+ paneHasKittyImage = true
562+ }
563+ }
564+ }
565+
566+ var w strings.Builder
567+ w.WriteString(SeqSynchronizedOn)
568+ w.WriteString(SeqHideCursor)
569+ for row, line := range pane.Lines {
570+ y := pane.Rect.Y + row
571+ if y < 0 || y >= r.rows {
572+ continue
573+ }
574+ if pane.Rect.X >= r.cols {
575+ continue
576+ }
577+ width := min(pane.Rect.Width, r.cols-pane.Rect.X)
578+ w.WriteString(MoveTo(y+1, pane.Rect.X+1))
579+ w.WriteString("\x1b[0m")
580+ w.WriteString(paintBackgroundRow(truncateToWidth(line, width), width, r.backgroundStyle))
581+ }
582+ if cursorRow >= 0 && cursorRow < r.rows && cursorCol >= 0 && cursorCol < r.cols {
583+ w.WriteString(MoveTo(cursorRow+1, cursorCol+1))
584+ w.WriteString(SeqShowCursor)
585+ }
586+ w.WriteString(SeqSynchronizedOff)
587+ _, _ = io.WriteString(r.out, w.String())
588+ r.prevHadImage = r.prevHadImage || paneHasImage
589+ r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage
590+ // Keep the dimmed background cache while this geometry remains active.
591+ r.floatingActive = true
592+ r.floatingRect = pane.Rect
593+}
594+
530 // DrawLog renders zut in the terminal's main screen as normal terminal595 // 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 once596 // flow rather than a fixed full-screen frame. Chat lines are emitted once
532 // into the host terminal scrollback; the current bottom block (dialogs,597 // into the host terminal scrollback; the current bottom block (dialogs,

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close