detached b3989ce

fix(tui): preserve floating dialog layout

commit
b3989ce2e8a80732a8e37b40e10d82853e5194b6
author
brice · · 2026-08-14 08:56 UTC
parent
9e66d9a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +137 -35
M packages/agent/modes/interactive_rendering.go +31 -3
@@ -561,8 +561,11 @@ func (i *Interactive) redraw() {
561561 i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
562562 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)
563563 }
564+ dialogTitle := ""
565+ dialogRemovedTopRows := 0
564566 if len(dialog) > 0 {
565567 dialog = padDialogFrame(dialog)
568+ dialogTitle, dialog, dialogRemovedTopRows = floatingDialogBody(dialog)
566569 }
567570 dialogFocusRow := -1
568571 for row, line := range dialog {
@@ -1024,7 +1027,10 @@ func (i *Interactive) redraw() {
10241027 }
10251028 dialogCursorRow, dialogCursorCol := dialogFocusRow, 0
10261029 if cursorInDialog {
1027- dialogCursorRow, dialogCursorCol = cursorRow, cursorCol
1030+ dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol
1031+ if dialogCursorRow < 0 {
1032+ dialogCursorRow = -1
1033+ }
10281034 }
10291035 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
10301036 if i.renderRevision.Load() != renderRevision {
@@ -1041,7 +1047,7 @@ func (i *Interactive) redraw() {
10411047 rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
10421048 background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth)
10431049 }
1044- pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)
1050+ pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)
10451051 i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol)
10461052 } else if rightBarActive {
10471053 rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
@@ -1059,6 +1065,25 @@ func (i *Interactive) redraw() {
10591065 i.mu.Unlock()
10601066 }
10611067
1068+// floatingDialogBody removes a legacy dialog's rule chrome and returns its
1069+// title for the shared floating-pane border.
1070+func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {
1071+ if len(lines) == 0 {
1072+ return "", nil, 0
1073+ }
1074+ body = lines
1075+ if isFrameHeaderLine(body[0]) {
1076+ plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ")
1077+ title = strings.TrimSpace(strings.TrimRight(plain, "─"))
1078+ body = body[1:]
1079+ removedTopRows = 1
1080+ }
1081+ if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) {
1082+ body = body[:last]
1083+ }
1084+ return title, body, removedTopRows
1085+}
1086+
10621087 // floatingBackgroundFrame preserves the regular viewport layout while an
10631088 // independent view is open. It is a fixed terminal frame only for the overlay
10641089 // lifetime; normal rendering immediately returns to scrollback-oriented flow
@@ -1078,7 +1103,10 @@ func floatingBackgroundFrame(chat, bottom []string, rows int) []string {
10781103 lines = lines[len(lines)-rows:]
10791104 }
10801105 frame := make([]string, rows)
1081- copy(frame[rows-len(lines):], lines)
1106+ // DrawLog's flow renderer leaves the logical session content at the
1107+ // viewport's top. Keep that anchoring when switching to a fixed floating
1108+ // frame so opening a pane does not visibly jump the whole session down.
1109+ copy(frame, lines)
10821110 return frame
10831111 }
10841112
@@ -561,8 +561,11 @@ func (i *Interactive) redraw() {
561 i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)561 i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
562 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)562 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)
563 }563 }
564+ dialogTitle := ""
565+ dialogRemovedTopRows := 0
564 if len(dialog) > 0 {566 if len(dialog) > 0 {
565 dialog = padDialogFrame(dialog)567 dialog = padDialogFrame(dialog)
568+ dialogTitle, dialog, dialogRemovedTopRows = floatingDialogBody(dialog)
566 }569 }
567 dialogFocusRow := -1570 dialogFocusRow := -1
568 for row, line := range dialog {571 for row, line := range dialog {
@@ -1024,7 +1027,10 @@ func (i *Interactive) redraw() {
1024 }1027 }
1025 dialogCursorRow, dialogCursorCol := dialogFocusRow, 01028 dialogCursorRow, dialogCursorCol := dialogFocusRow, 0
1026 if cursorInDialog {1029 if cursorInDialog {
1027- dialogCursorRow, dialogCursorCol = cursorRow, cursorCol1030+ dialogCursorRow, dialogCursorCol = cursorRow-dialogRemovedTopRows, cursorCol
1031+ if dialogCursorRow < 0 {
1032+ dialogCursorRow = -1
1033+ }
1028 }1034 }
1029 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)1035 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
1030 if i.renderRevision.Load() != renderRevision {1036 if i.renderRevision.Load() != renderRevision {
@@ -1041,7 +1047,7 @@ func (i *Interactive) redraw() {
1041 rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)1047 rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1042 background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth)1048 background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth)
1043 }1049 }
1044- pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)1050+ pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)
1045 i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol)1051 i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol)
1046 } else if rightBarActive {1052 } else if rightBarActive {
1047 rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)1053 rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
@@ -1059,6 +1065,25 @@ func (i *Interactive) redraw() {
1059 i.mu.Unlock()1065 i.mu.Unlock()
1060 }1066 }
1061 1067
1068+// floatingDialogBody removes a legacy dialog's rule chrome and returns its
1069+// title for the shared floating-pane border.
1070+func floatingDialogBody(lines []string) (title string, body []string, removedTopRows int) {
1071+ if len(lines) == 0 {
1072+ return "", nil, 0
1073+ }
1074+ body = lines
1075+ if isFrameHeaderLine(body[0]) {
1076+ plain := strings.TrimPrefix(stripANSIBytes(body[0]), "── ")
1077+ title = strings.TrimSpace(strings.TrimRight(plain, "─"))
1078+ body = body[1:]
1079+ removedTopRows = 1
1080+ }
1081+ if last := len(body) - 1; last >= 0 && isFrameRuleLine(body[last]) {
1082+ body = body[:last]
1083+ }
1084+ return title, body, removedTopRows
1085+}
1086+
1062 // floatingBackgroundFrame preserves the regular viewport layout while an1087 // floatingBackgroundFrame preserves the regular viewport layout while an
1063 // independent view is open. It is a fixed terminal frame only for the overlay1088 // independent view is open. It is a fixed terminal frame only for the overlay
1064 // lifetime; normal rendering immediately returns to scrollback-oriented flow1089 // lifetime; normal rendering immediately returns to scrollback-oriented flow
@@ -1078,7 +1103,10 @@ func floatingBackgroundFrame(chat, bottom []string, rows int) []string {
1078 lines = lines[len(lines)-rows:]1103 lines = lines[len(lines)-rows:]
1079 }1104 }
1080 frame := make([]string, rows)1105 frame := make([]string, rows)
1081- copy(frame[rows-len(lines):], lines)1106+ // DrawLog's flow renderer leaves the logical session content at the
1107+ // viewport's top. Keep that anchoring when switching to a fixed floating
1108+ // frame so opening a pane does not visibly jump the whole session down.
1109+ copy(frame, lines)
1082 return frame1110 return frame
1083 }1111 }
1084 1112
M packages/agent/modes/modal_backdrop_test.go +51 -12
@@ -7,11 +7,47 @@ import (
77 "github.com/bnema/zut/packages/tui"
88 )
99
10-func TestFloatingBackgroundFrameKeepsNewestOversizedBottomRows(t *testing.T) {
11- got := floatingBackgroundFrame([]string{"chat"}, []string{"old", "newer", "newest"}, 3)
12- want := []string{"newer", "newest", ""}
13- if strings.Join(got, "\n") != strings.Join(want, "\n") {
14- t.Fatalf("floating background = %q, want %q", got, want)
10+func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) {
11+ lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)})
12+ title, body, removed := floatingDialogBody(lines)
13+ if title != "settings" || removed != 1 {
14+ t.Fatalf("chrome extraction = title:%q removed:%d, want settings/1", title, removed)
15+ }
16+ if strings.Join(body, "\n") != "\nbody\n" {
17+ t.Fatalf("dialog body retained frame chrome: %q", body)
18+ }
19+}
20+
21+func TestFloatingBackgroundFramePreservesTopAnchoredViewport(t *testing.T) {
22+ tests := []struct {
23+ name string
24+ chat []string
25+ bottom []string
26+ rows int
27+ want []string
28+ }{
29+ {
30+ name: "session stays at top",
31+ chat: []string{"chat"},
32+ bottom: []string{"status"},
33+ rows: 5,
34+ want: []string{"chat", "status", "", "", ""},
35+ },
36+ {
37+ name: "oversized bottom retains newest rows",
38+ chat: []string{"chat"},
39+ bottom: []string{"old", "newer", "newest"},
40+ rows: 3,
41+ want: []string{"newer", "newest", ""},
42+ },
43+ }
44+ for _, tt := range tests {
45+ t.Run(tt.name, func(t *testing.T) {
46+ got := floatingBackgroundFrame(tt.chat, tt.bottom, tt.rows)
47+ if strings.Join(got, "\n") != strings.Join(tt.want, "\n") {
48+ t.Fatalf("floating background = %q, want %q", got, tt.want)
49+ }
50+ })
1551 }
1652 }
1753
@@ -32,15 +68,18 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
3268 t.Fatalf("settings dialog left the main input undimmed: %q", got)
3369 }
3470 pane := tui.FloatingPaneMaxRect(80, 24)
35- header := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth())
36- if !strings.Contains(got, header) {
37- t.Fatalf("settings dialog was not rendered inside the floating pane: %q", got)
71+ innerHeader := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth())
72+ if strings.Contains(got, innerHeader) {
73+ t.Fatalf("settings retained its inner frame header: %q", got)
74+ }
75+ if !strings.Contains(got, " settings ") {
76+ t.Fatalf("settings title was not rendered in the floating border: %q", got)
3877 }
39- if strings.Contains(got, tui.Dim(header)) {
40- t.Fatalf("settings dialog itself was dimmed: %q", got)
78+ if strings.Contains(got, tui.Dim(" settings ")) {
79+ t.Fatalf("floating pane title was dimmed: %q", got)
4180 }
42- // The pane shrinks to its content and remains horizontally centered.
43- if !strings.Contains(got, tui.MoveTo(9, pane.X+1)) {
81+ // The pane shrinks to its chrome-free content and remains centered.
82+ if !strings.Contains(got, tui.MoveTo(10, pane.X+1)) {
4483 t.Fatalf("floating pane was not positioned at its computed geometry: %q", got)
4584 }
4685 cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))
@@ -7,11 +7,47 @@ import (
7 "github.com/bnema/zut/packages/tui"7 "github.com/bnema/zut/packages/tui"
8 )8 )
9 9
10-func TestFloatingBackgroundFrameKeepsNewestOversizedBottomRows(t *testing.T) {10+func TestFloatingDialogBodyMovesFrameTitleIntoPaneBorder(t *testing.T) {
11- got := floatingBackgroundFrame([]string{"chat"}, []string{"old", "newer", "newest"}, 3)11+ lines := padDialogFrame([]string{frameHeader(tui.Dark, "settings", 40), "body", frameRule(tui.Dark, 40)})
12- want := []string{"newer", "newest", ""}12+ title, body, removed := floatingDialogBody(lines)
13- if strings.Join(got, "\n") != strings.Join(want, "\n") {13+ if title != "settings" || removed != 1 {
14- t.Fatalf("floating background = %q, want %q", got, want)14+ t.Fatalf("chrome extraction = title:%q removed:%d, want settings/1", title, removed)
15+ }
16+ if strings.Join(body, "\n") != "\nbody\n" {
17+ t.Fatalf("dialog body retained frame chrome: %q", body)
18+ }
19+}
20+
21+func TestFloatingBackgroundFramePreservesTopAnchoredViewport(t *testing.T) {
22+ tests := []struct {
23+ name string
24+ chat []string
25+ bottom []string
26+ rows int
27+ want []string
28+ }{
29+ {
30+ name: "session stays at top",
31+ chat: []string{"chat"},
32+ bottom: []string{"status"},
33+ rows: 5,
34+ want: []string{"chat", "status", "", "", ""},
35+ },
36+ {
37+ name: "oversized bottom retains newest rows",
38+ chat: []string{"chat"},
39+ bottom: []string{"old", "newer", "newest"},
40+ rows: 3,
41+ want: []string{"newer", "newest", ""},
42+ },
43+ }
44+ for _, tt := range tests {
45+ t.Run(tt.name, func(t *testing.T) {
46+ got := floatingBackgroundFrame(tt.chat, tt.bottom, tt.rows)
47+ if strings.Join(got, "\n") != strings.Join(tt.want, "\n") {
48+ t.Fatalf("floating background = %q, want %q", got, tt.want)
49+ }
50+ })
15 }51 }
16 }52 }
17 53
@@ -32,15 +68,18 @@ func TestSettingsDialogDimsBackdropAndMainCursor(t *testing.T) {
32 t.Fatalf("settings dialog left the main input undimmed: %q", got)68 t.Fatalf("settings dialog left the main input undimmed: %q", got)
33 }69 }
34 pane := tui.FloatingPaneMaxRect(80, 24)70 pane := tui.FloatingPaneMaxRect(80, 24)
35- header := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth())71+ innerHeader := frameHeader(i.cfg.Theme, "settings", pane.ContentWidth())
36- if !strings.Contains(got, header) {72+ if strings.Contains(got, innerHeader) {
37- t.Fatalf("settings dialog was not rendered inside the floating pane: %q", got)73+ t.Fatalf("settings retained its inner frame header: %q", got)
74+ }
75+ if !strings.Contains(got, " settings ") {
76+ t.Fatalf("settings title was not rendered in the floating border: %q", got)
38 }77 }
39- if strings.Contains(got, tui.Dim(header)) {78+ if strings.Contains(got, tui.Dim(" settings ")) {
40- t.Fatalf("settings dialog itself was dimmed: %q", got)79+ t.Fatalf("floating pane title was dimmed: %q", got)
41 }80 }
42- // The pane shrinks to its content and remains horizontally centered.81+ // The pane shrinks to its chrome-free content and remains centered.
43- if !strings.Contains(got, tui.MoveTo(9, pane.X+1)) {82+ if !strings.Contains(got, tui.MoveTo(10, pane.X+1)) {
44 t.Fatalf("floating pane was not positioned at its computed geometry: %q", got)83 t.Fatalf("floating pane was not positioned at its computed geometry: %q", got)
45 }84 }
46 cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))85 cursor := tui.CursorColor(i.cfg.Theme.DimColor(tui.Color256(15), modalBackdropDimPercent))
M packages/tui/floating_pane.go +36 -9
@@ -101,11 +101,11 @@ type FloatingPaneFrame struct {
101101 Rect FloatingPaneRect
102102 }
103103
104-// Compose returns a complete floating-pane frame. contentCursorRow is relative
105-// to content and is used both to retain the pane's own viewport and to return
106-// an absolute terminal cursor row. Pass -1 when the foreground has no text
107-// cursor.
108-func (p *FloatingPane) Compose(theme Theme, id string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) {
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) {
109109 pane.Background = DimLines(fitFloatingBackground(background, cols, rows))
110110 if cols < 1 || rows < 1 {
111111 return pane, -1, 0
@@ -141,7 +141,7 @@ func (p *FloatingPane) Compose(theme Theme, id string, background, content []str
141141 viewportRows := pane.Rect.ContentHeight()
142142 p.follow(contentCursorRow, len(content), viewportRows)
143143 content = p.viewport(content, viewportRows)
144- pane.Lines = floatingPaneLines(theme, pane.Rect, content)
144+ pane.Lines = floatingPaneLines(theme, pane.Rect, title, content)
145145
146146 if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows {
147147 cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll
@@ -200,7 +200,7 @@ func fitFloatingBackground(background []string, cols, rows int) []string {
200200 return frame
201201 }
202202
203-func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []string {
203+func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string {
204204 if rect.Width < 1 || rect.Height < 1 {
205205 return nil
206206 }
@@ -212,7 +212,7 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s
212212 }
213213 lines := make([]string, rect.Height)
214214 if rect.Drawer {
215- lines[0] = theme.FGColor(theme.Muted, strings.Repeat("─", rect.Width))
215+ lines[0] = floatingPaneTitle(theme, title, rect.Width, false)
216216 for row := 0; row < rect.ContentHeight(); row++ {
217217 line := ""
218218 if row < len(content) {
@@ -226,7 +226,7 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s
226226 return lines
227227 }
228228 border := theme.FGColor(theme.Muted, "│")
229- lines[0] = theme.FGColor(theme.Muted, "╭"+strings.Repeat("─", rect.Width-2)+"╮")
229+ lines[0] = floatingPaneTitle(theme, title, rect.Width, true)
230230 lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯")
231231 for row := 0; row < rect.ContentHeight(); row++ {
232232 line := ""
@@ -240,3 +240,30 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s
240240 }
241241 return lines
242242 }
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+}
@@ -101,11 +101,11 @@ type FloatingPaneFrame struct {
101 Rect FloatingPaneRect101 Rect FloatingPaneRect
102 }102 }
103 103
104-// Compose returns a complete floating-pane frame. contentCursorRow is relative104+// Compose returns a complete floating-pane frame. title is centered in the
105-// to content and is used both to retain the pane's own viewport and to return105+// pane border. contentCursorRow is relative to content and is used both to
106-// an absolute terminal cursor row. Pass -1 when the foreground has no text106+// retain the pane's own viewport and to return an absolute terminal cursor
107-// cursor.107+// row. Pass -1 when the foreground has no text cursor.
108-func (p *FloatingPane) Compose(theme Theme, id string, background, content []string, cols, rows, contentCursorRow, contentCursorCol int) (pane FloatingPaneFrame, cursorRow, cursorCol int) {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))109 pane.Background = DimLines(fitFloatingBackground(background, cols, rows))
110 if cols < 1 || rows < 1 {110 if cols < 1 || rows < 1 {
111 return pane, -1, 0111 return pane, -1, 0
@@ -141,7 +141,7 @@ func (p *FloatingPane) Compose(theme Theme, id string, background, content []str
141 viewportRows := pane.Rect.ContentHeight()141 viewportRows := pane.Rect.ContentHeight()
142 p.follow(contentCursorRow, len(content), viewportRows)142 p.follow(contentCursorRow, len(content), viewportRows)
143 content = p.viewport(content, viewportRows)143 content = p.viewport(content, viewportRows)
144- pane.Lines = floatingPaneLines(theme, pane.Rect, content)144+ pane.Lines = floatingPaneLines(theme, pane.Rect, title, content)
145 145
146 if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows {146 if contentCursorRow >= p.scroll && contentCursorRow < p.scroll+viewportRows {
147 cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll147 cursorRow = pane.Rect.contentY() + contentCursorRow - p.scroll
@@ -200,7 +200,7 @@ func fitFloatingBackground(background []string, cols, rows int) []string {
200 return frame200 return frame
201 }201 }
202 202
203-func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []string {203+func floatingPaneLines(theme Theme, rect FloatingPaneRect, title string, content []string) []string {
204 if rect.Width < 1 || rect.Height < 1 {204 if rect.Width < 1 || rect.Height < 1 {
205 return nil205 return nil
206 }206 }
@@ -212,7 +212,7 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s
212 }212 }
213 lines := make([]string, rect.Height)213 lines := make([]string, rect.Height)
214 if rect.Drawer {214 if rect.Drawer {
215- lines[0] = theme.FGColor(theme.Muted, strings.Repeat("─", rect.Width))215+ lines[0] = floatingPaneTitle(theme, title, rect.Width, false)
216 for row := 0; row < rect.ContentHeight(); row++ {216 for row := 0; row < rect.ContentHeight(); row++ {
217 line := ""217 line := ""
218 if row < len(content) {218 if row < len(content) {
@@ -226,7 +226,7 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s
226 return lines226 return lines
227 }227 }
228 border := theme.FGColor(theme.Muted, "│")228 border := theme.FGColor(theme.Muted, "│")
229- lines[0] = theme.FGColor(theme.Muted, "╭"+strings.Repeat("─", rect.Width-2)+"╮")229+ lines[0] = floatingPaneTitle(theme, title, rect.Width, true)
230 lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯")230 lines[len(lines)-1] = theme.FGColor(theme.Muted, "╰"+strings.Repeat("─", rect.Width-2)+"╯")
231 for row := 0; row < rect.ContentHeight(); row++ {231 for row := 0; row < rect.ContentHeight(); row++ {
232 line := ""232 line := ""
@@ -240,3 +240,30 @@ func floatingPaneLines(theme Theme, rect FloatingPaneRect, content []string) []s
240 }240 }
241 return lines241 return lines
242 }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+}
M packages/tui/floating_pane_test.go +19 -11
@@ -7,7 +7,7 @@ import (
77
88 func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {
99 var pane FloatingPane
10- frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0)
10+ frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0)
1111
1212 if frame.Rect.Drawer {
1313 t.Fatal("100-column terminal unexpectedly used drawer layout")
@@ -20,6 +20,14 @@ func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {
2020 }
2121 if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") {
2222 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+ }
2331 }
2432 if cursorRow != -1 || cursorCol != 0 {
2533 t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol)
@@ -28,7 +36,7 @@ func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {
2836
2937 func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) {
3038 var pane FloatingPane
31- frame, _, _ := pane.Compose(Dark, "settings", nil, []string{"settings body"}, 79, 24, -1, 0)
39+ frame, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"settings body"}, 79, 24, -1, 0)
3240
3341 if !frame.Rect.Drawer {
3442 t.Fatal("79-column terminal did not use drawer layout")
@@ -36,15 +44,15 @@ func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) {
3644 if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 {
3745 t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect)
3846 }
39- if got := stripANSI(frame.Lines[0]); strings.Trim(got, "─") != "" {
40- t.Fatalf("drawer top border = %q, want rule only", got)
47+ if got := stripANSI(frame.Lines[0]); !strings.Contains(got, "settings") {
48+ t.Fatalf("drawer title = %q, want centered settings title", got)
4149 }
4250 }
4351
4452 func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) {
4553 var pane FloatingPane
46- wide, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 100, 30, -1, 0)
47- narrow, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 79, 24, -1, 0)
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)
4856
4957 if wide.Rect.Drawer {
5058 t.Fatal("wide terminal unexpectedly used a drawer")
@@ -56,12 +64,12 @@ func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) {
5664
5765 func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) {
5866 var pane FloatingPane
59- frame, _, col := pane.Compose(Dark, "search", nil, []string{"query"}, 100, 20, 0, 1000)
67+ frame, _, col := pane.Compose(Dark, "search", "search", nil, []string{"query"}, 100, 20, 0, 1000)
6068 if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want {
6169 t.Fatalf("cursor col = %d, want content edge %d", col, want)
6270 }
6371
64- tiny, row, tinyCol := pane.Compose(Dark, "search", nil, []string{"x"}, 2, 2, 0, 10)
72+ tiny, row, tinyCol := pane.Compose(Dark, "search", "search", nil, []string{"x"}, 2, 2, 0, 10)
6573 if !tiny.Rect.Borderless {
6674 t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect)
6775 }
@@ -76,7 +84,7 @@ func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.
7684 content[row] = "row " + itoa(row)
7785 }
7886 var pane FloatingPane
79- frame, cursorRow, _ := pane.Compose(Dark, "settings", nil, content, 100, 12, 25, 0)
87+ frame, cursorRow, _ := pane.Compose(Dark, "settings", "settings", nil, content, 100, 12, 25, 0)
8088 if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 {
8189 t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect)
8290 }
@@ -84,7 +92,7 @@ func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.
8492 t.Fatal("focused row was not retained in the pane viewport")
8593 }
8694
87- other, _, _ := pane.Compose(Dark, "subagents", nil, content, 100, 12, -1, 0)
95+ other, _, _ := pane.Compose(Dark, "subagents", "subagents", nil, content, 100, 12, -1, 0)
8896 if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") {
8997 t.Fatal("new view inherited the previous view's scroll position")
9098 }
@@ -95,7 +103,7 @@ func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testi
95103 r := NewRenderer(&out)
96104 r.Resize(100, 20)
97105 var pane FloatingPane
98- frame, _, _ := pane.Compose(Dark, "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0)
106+ frame, _, _ := pane.Compose(Dark, "settings", "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0)
99107 r.DrawFloating(frame, -1, 0)
100108 got := out.String()
101109 if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") {
@@ -7,7 +7,7 @@ import (
7 7
8 func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {8 func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {
9 var pane FloatingPane9 var pane FloatingPane
10- frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0)10+ frame, cursorRow, cursorCol := pane.Compose(Dark, "settings", "settings", []string{"live transcript", "draft input"}, []string{"settings body"}, 100, 30, -1, 0)
11 11
12 if frame.Rect.Drawer {12 if frame.Rect.Drawer {
13 t.Fatal("100-column terminal unexpectedly used drawer layout")13 t.Fatal("100-column terminal unexpectedly used drawer layout")
@@ -20,6 +20,14 @@ func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {
20 }20 }
21 if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") {21 if got := stripANSI(frame.Lines[0]); !strings.HasPrefix(got, "╭") {
22 t.Fatalf("pane is missing top border: %q", 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+ }
23 }31 }
24 if cursorRow != -1 || cursorCol != 0 {32 if cursorRow != -1 || cursorCol != 0 {
25 t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol)33 t.Fatalf("cursor = (%d, %d), want hidden", cursorRow, cursorCol)
@@ -28,7 +36,7 @@ func TestFloatingPaneComposeCentersAndDimsLiveBackground(t *testing.T) {
28 36
29 func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) {37 func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) {
30 var pane FloatingPane38 var pane FloatingPane
31- frame, _, _ := pane.Compose(Dark, "settings", nil, []string{"settings body"}, 79, 24, -1, 0)39+ frame, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"settings body"}, 79, 24, -1, 0)
32 40
33 if !frame.Rect.Drawer {41 if !frame.Rect.Drawer {
34 t.Fatal("79-column terminal did not use drawer layout")42 t.Fatal("79-column terminal did not use drawer layout")
@@ -36,15 +44,15 @@ func TestFloatingPaneUsesBottomDrawerBelowEightyColumns(t *testing.T) {
36 if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 {44 if frame.Rect.X != 0 || frame.Rect.Width != 79 || frame.Rect.Y+frame.Rect.Height != 24 {
37 t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect)45 t.Fatalf("drawer geometry = %+v, want full-width bottom drawer", frame.Rect)
38 }46 }
39- if got := stripANSI(frame.Lines[0]); strings.Trim(got, "─") != "" {47+ if got := stripANSI(frame.Lines[0]); !strings.Contains(got, "settings") {
40- t.Fatalf("drawer top border = %q, want rule only", got)48+ t.Fatalf("drawer title = %q, want centered settings title", got)
41 }49 }
42 }50 }
43 51
44 func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) {52 func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) {
45 var pane FloatingPane53 var pane FloatingPane
46- wide, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 100, 30, -1, 0)54+ wide, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 100, 30, -1, 0)
47- narrow, _, _ := pane.Compose(Dark, "settings", nil, []string{"setting"}, 79, 24, -1, 0)55+ narrow, _, _ := pane.Compose(Dark, "settings", "settings", nil, []string{"setting"}, 79, 24, -1, 0)
48 56
49 if wide.Rect.Drawer {57 if wide.Rect.Drawer {
50 t.Fatal("wide terminal unexpectedly used a drawer")58 t.Fatal("wide terminal unexpectedly used a drawer")
@@ -56,12 +64,12 @@ func TestFloatingPaneRecomputesGeometryOnResize(t *testing.T) {
56 64
57 func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) {65 func TestFloatingPaneClampsCursorToContentAndHandlesTinyTerminals(t *testing.T) {
58 var pane FloatingPane66 var pane FloatingPane
59- frame, _, col := pane.Compose(Dark, "search", nil, []string{"query"}, 100, 20, 0, 1000)67+ frame, _, col := pane.Compose(Dark, "search", "search", nil, []string{"query"}, 100, 20, 0, 1000)
60 if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want {68 if want := frame.Rect.contentX() + frame.Rect.ContentWidth() - 1; col != want {
61 t.Fatalf("cursor col = %d, want content edge %d", col, want)69 t.Fatalf("cursor col = %d, want content edge %d", col, want)
62 }70 }
63 71
64- tiny, row, tinyCol := pane.Compose(Dark, "search", nil, []string{"x"}, 2, 2, 0, 10)72+ tiny, row, tinyCol := pane.Compose(Dark, "search", "search", nil, []string{"x"}, 2, 2, 0, 10)
65 if !tiny.Rect.Borderless {73 if !tiny.Rect.Borderless {
66 t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect)74 t.Fatalf("tiny terminal rect = %+v, want borderless", tiny.Rect)
67 }75 }
@@ -76,7 +84,7 @@ func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.
76 content[row] = "row " + itoa(row)84 content[row] = "row " + itoa(row)
77 }85 }
78 var pane FloatingPane86 var pane FloatingPane
79- frame, cursorRow, _ := pane.Compose(Dark, "settings", nil, content, 100, 12, 25, 0)87+ frame, cursorRow, _ := pane.Compose(Dark, "settings", "settings", nil, content, 100, 12, 25, 0)
80 if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 {88 if cursorRow < frame.Rect.Y+1 || cursorRow >= frame.Rect.Y+frame.Rect.Height-1 {
81 t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect)89 t.Fatalf("focused cursor row %d is outside pane %+v", cursorRow, frame.Rect)
82 }90 }
@@ -84,7 +92,7 @@ func TestFloatingPaneKeepsFocusedContentVisibleAndResetsForOtherView(t *testing.
84 t.Fatal("focused row was not retained in the pane viewport")92 t.Fatal("focused row was not retained in the pane viewport")
85 }93 }
86 94
87- other, _, _ := pane.Compose(Dark, "subagents", nil, content, 100, 12, -1, 0)95+ other, _, _ := pane.Compose(Dark, "subagents", "subagents", nil, content, 100, 12, -1, 0)
88 if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") {96 if strings.Contains(stripANSI(strings.Join(other.Lines, "\n")), "row 25") {
89 t.Fatal("new view inherited the previous view's scroll position")97 t.Fatal("new view inherited the previous view's scroll position")
90 }98 }
@@ -95,7 +103,7 @@ func TestRendererDrawFloatingPaintsBackgroundThenPaneAtAbsolutePosition(t *testi
95 r := NewRenderer(&out)103 r := NewRenderer(&out)
96 r.Resize(100, 20)104 r.Resize(100, 20)
97 var pane FloatingPane105 var pane FloatingPane
98- frame, _, _ := pane.Compose(Dark, "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0)106+ frame, _, _ := pane.Compose(Dark, "settings", "settings", []string{"background"}, []string{"foreground"}, 100, 20, -1, 0)
99 r.DrawFloating(frame, -1, 0)107 r.DrawFloating(frame, -1, 0)
100 got := out.String()108 got := out.String()
101 if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") {109 if !strings.Contains(got, Dim("background")) || !strings.Contains(got, "foreground") {

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close