detached 352bb42

Merge pull request #211 from m1k3s0/fix/settings-dialog-short-terminal

Fix settings dialog clipping the top entries on short terminals
commit
352bb42ada2c60b5a02f979b994cc9d3e4516bb0
author
Patric Eckhart · · 2026-09-26 10:41 UTC
committer
GitHub · · 2026-09-26 10:41 UTC
merge of
ff10c69 + f425a49 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.99
3 files · +447 -35
M packages/agent/modes/interactive.go +14 -0
@@ -1411,6 +1411,20 @@ func (i *Interactive) redraw() {
14111411 case i.telegramDialog.Active():
14121412 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
14131413 case i.settingsDialog.Active():
1414+ // Reserve rows for the editor, the status line, the blank
1415+ // separators, and one chat row so the settings list scrolls
1416+ // inside the dialog when the terminal is short instead of
1417+ // having its top entries clipped off and unreachable.
1418+ // Mirrors the reservation made for the session picker above.
1419+ _, rows := i.cfg.Terminal.Size()
1420+ avail := rows - 12
1421+ // Floor at the frame chrome (header + hint + rule) plus one
1422+ // list row so even a hand-sized terminal draws the row the
1423+ // cursor is on rather than nothing.
1424+ if avail < 4 {
1425+ avail = 4
1426+ }
1427+ i.settingsDialog.MaxRows = avail
14141428 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
14151429 case i.sessionOpsDialog.Active():
14161430 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
@@ -1411,6 +1411,20 @@ func (i *Interactive) redraw() {
1411 case i.telegramDialog.Active():1411 case i.telegramDialog.Active():
1412 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)1412 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
1413 case i.settingsDialog.Active():1413 case i.settingsDialog.Active():
1414+ // Reserve rows for the editor, the status line, the blank
1415+ // separators, and one chat row so the settings list scrolls
1416+ // inside the dialog when the terminal is short instead of
1417+ // having its top entries clipped off and unreachable.
1418+ // Mirrors the reservation made for the session picker above.
1419+ _, rows := i.cfg.Terminal.Size()
1420+ avail := rows - 12
1421+ // Floor at the frame chrome (header + hint + rule) plus one
1422+ // list row so even a hand-sized terminal draws the row the
1423+ // cursor is on rather than nothing.
1424+ if avail < 4 {
1425+ avail = 4
1426+ }
1427+ i.settingsDialog.MaxRows = avail
1414 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)1428 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
1415 case i.sessionOpsDialog.Active():1429 case i.sessionOpsDialog.Active():
1416 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)1430 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
M packages/agent/modes/settings_dialog.go +202 -35
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "fmt"
45 "strings"
56
67 "github.com/mattn/go-runewidth"
@@ -18,6 +19,18 @@ type settingsDialog struct {
1819 optionCursor int
1920 parentItems []settingsItem
2021 parentCursor int
22+
23+ // MaxRows caps how many rows the dialog renders. The host sets it
24+ // from the terminal height before each render; zero renders the
25+ // list in full. A list taller than the cap scrolls in a window
26+ // that follows the cursor so entries never disappear off-screen.
27+ MaxRows int
28+
29+ // viewTop is the index of the first entry drawn in the current
30+ // window. It survives across renders so the window only moves when
31+ // the cursor pushes past one of its edges, and is reset whenever
32+ // the dialog swaps between the item list and an option list.
33+ viewTop int
2134 }
2235
2336 type settingsItem struct {
@@ -62,6 +75,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
6275 d.optionCursor = 0
6376 d.parentItems = nil
6477 d.parentCursor = 0
78+ d.viewTop = 0
6579 d.active = true
6680 return true
6781 }
@@ -81,6 +95,7 @@ func (d *settingsDialog) OpenDirectOption(item settingsItem) bool {
8195 }
8296 d.parentItems = nil
8397 d.parentCursor = 0
98+ d.viewTop = 0
8499 d.active = true
85100 return true
86101 }
@@ -90,6 +105,7 @@ func (d *settingsDialog) Close() {
90105 d.selecting = false
91106 d.direct = false
92107 d.parentItems = nil
108+ d.viewTop = 0
93109 }
94110 func (d *settingsDialog) Active() bool { return d != nil && d.active }
95111
@@ -119,6 +135,7 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
119135 d.cursor = d.parentCursor
120136 d.parentItems = nil
121137 d.parentCursor = 0
138+ d.viewTop = 0
122139 d.title = "settings"
123140 return settingsAction{}
124141 }
@@ -151,6 +168,7 @@ func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
151168 return settingsAction{Close: true}
152169 }
153170 d.selecting = false
171+ d.viewTop = 0
154172 case tui.KeyEnter:
155173 return d.selectCurrentOption()
156174 case tui.KeyRune:
@@ -188,6 +206,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
188206 d.items = it.children
189207 d.cursor = 0
190208 d.optionCursor = 0
209+ d.viewTop = 0
191210 d.title = "settings: " + it.label
192211 return settingsAction{}
193212 }
@@ -197,6 +216,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
197216 d.optionCursor = 0
198217 }
199218 d.selecting = true
219+ d.viewTop = 0
200220 return settingsAction{}
201221 }
202222 it.value = !it.value
@@ -220,6 +240,7 @@ func (d *settingsDialog) selectCurrentOption() settingsAction {
220240 it.choice = d.optionCursor
221241 d.items[d.cursor] = it
222242 d.selecting = false
243+ d.viewTop = 0
223244 action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
224245 if d.direct {
225246 d.Close()
@@ -242,39 +263,175 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
242263 } else {
243264 lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))
244265 }
245- for i, it := range d.items {
246- box := "[ ]"
247- if it.value {
248- box = "[✓]"
249- }
250- plain := " " + box + " " + it.label
251- if it.picker || len(it.children) > 0 {
252- box = "[→]"
253- plain = " " + box + " " + it.label
254- } else if len(it.options) > 0 {
255- box = "[→]"
256- if it.choice < 0 || it.choice >= len(it.options) {
257- it.choice = 0
258- }
259- plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
260- }
261- if it.hint != "" {
262- plain += " " + th.FG256(th.Muted, "("+it.hint+")")
266+
267+ // Render every entry to its own block up front so the row window
268+ // can count wrapped descriptions, then draw only the blocks that
269+ // fit the budget. Windowing keeps the cursor entry on screen: a
270+ // list taller than the terminal scrolls instead of having its top
271+ // entries clipped off and unreachable.
272+ blocks := make([][]string, len(d.items))
273+ heights := make([]int, len(d.items))
274+ for i := range d.items {
275+ blocks[i] = d.itemBlock(th, width, i)
276+ heights[i] = len(blocks[i])
277+ }
278+ budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
279+ start, end := d.windowBlocks(heights, d.cursor, budget)
280+ lines = append(lines, windowLines(th, blocks, start, end, budget)...)
281+ lines = append(lines, frameRule(th, width))
282+ return lines
283+}
284+
285+// itemBlock renders one entry as its label row followed by any wrapped
286+// description rows. Keeping both in one block lets the row window
287+// count the description and trim it when space runs out without losing
288+// the label row the cursor highlight sits on.
289+func (d *settingsDialog) itemBlock(th tui.Theme, width, idx int) []string {
290+ it := d.items[idx]
291+ box := "[ ]"
292+ if it.value {
293+ box = "[✓]"
294+ }
295+ plain := " " + box + " " + it.label
296+ if it.picker || len(it.children) > 0 {
297+ box = "[→]"
298+ plain = " " + box + " " + it.label
299+ } else if len(it.options) > 0 {
300+ box = "[→]"
301+ if it.choice < 0 || it.choice >= len(it.options) {
302+ it.choice = 0
263303 }
264- if it.disabled {
265- lines = append(lines, th.FG256(th.Muted, plain))
266- } else if i == d.cursor {
267- lines = append(lines, th.PadHighlight(plain, width))
268- } else {
269- lines = append(lines, plain)
304+ plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
305+ }
306+ if it.hint != "" {
307+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
308+ }
309+ line := plain
310+ switch {
311+ case it.disabled:
312+ line = th.FG256(th.Muted, plain)
313+ case idx == d.cursor:
314+ line = th.PadHighlight(plain, width)
315+ }
316+ block := []string{line}
317+ for _, desc := range wrapSettingDescription(it.desc, width, 6) {
318+ block = append(block, th.FG256(th.Muted, desc))
319+ }
320+ return block
321+}
322+
323+// rowBudget returns the rows left for the window after the fixed rows
324+// around it (frame header, hint, closing rule) are counted against
325+// MaxRows. Zero means unbounded.
326+func (d *settingsDialog) rowBudget(fixed int) int {
327+ if d.MaxRows <= 0 {
328+ return 0
329+ }
330+ if budget := d.MaxRows - fixed; budget > 0 {
331+ return budget
332+ }
333+ return 1
334+}
335+
336+// windowBlocks returns the [start, end) range of entry blocks to draw
337+// so the cursor entry stays visible. Heights are the rendered row
338+// counts per entry, budget is the rows available for the blocks plus
339+// the "more above/below" markers, and zero means windowing is off.
340+func (d *settingsDialog) windowBlocks(heights []int, cursor, budget int) (start, end int) {
341+ total := len(heights)
342+ if total == 0 {
343+ return 0, 0
344+ }
345+ if budget <= 0 {
346+ d.viewTop = 0
347+ return 0, total
348+ }
349+ if cursor < 0 {
350+ cursor = 0
351+ }
352+ if cursor >= total {
353+ cursor = total - 1
354+ }
355+ rows := 0
356+ for _, h := range heights {
357+ rows += h
358+ }
359+ if rows <= budget {
360+ d.viewTop = 0
361+ return 0, total
362+ }
363+ // Entries are hidden above and/or below, so hold back a row for
364+ // the markers. On a terminal too short for the cursor entry plus
365+ // markers, drop the reservation: the highlighted row matters more
366+ // than the hint that more entries exist.
367+ inner := budget - 2
368+ if inner < heights[cursor] {
369+ inner = budget
370+ }
371+ return d.anchorBlocks(heights, cursor, inner)
372+}
373+
374+// anchorBlocks returns the block range of combined height <= budget
375+// that keeps the cursor entry visible. The window extends downward
376+// from the persistent viewTop; when the cursor moves past either edge
377+// the window re-anchors so the cursor entry becomes the first or last
378+// visible one. viewTop is updated for the next render so the window
379+// stays put while the cursor moves inside it.
380+func (d *settingsDialog) anchorBlocks(heights []int, cursor, budget int) (start, end int) {
381+ total := len(heights)
382+ start = d.viewTop
383+ if start > cursor || start >= total {
384+ start = cursor
385+ }
386+ used := 0
387+ for end = start; end < total && (end == start || used+heights[end] <= budget); end++ {
388+ used += heights[end]
389+ }
390+ if cursor >= end {
391+ // The cursor fell past the bottom edge: re-anchor on it and
392+ // fill the remaining budget with entries above.
393+ start, end = cursor, cursor+1
394+ used = heights[cursor]
395+ for start > 0 && used+heights[start-1] <= budget {
396+ start--
397+ used += heights[start]
270398 }
271- if it.desc != "" {
272- for _, desc := range wrapSettingDescription(it.desc, width, 6) {
273- lines = append(lines, th.FG256(th.Muted, desc))
399+ }
400+ d.viewTop = start
401+ return start, end
402+}
403+
404+// windowLines renders the [start, end) slice of entry blocks, framed by
405+// muted "more above/below" markers when entries are hidden on either
406+// side. Blocks are trimmed so the result never uses more than budget
407+// rows; budget <= 0 means unbounded.
408+func windowLines(th tui.Theme, blocks [][]string, start, end, budget int) []string {
409+ unbounded := budget <= 0
410+ remaining := budget
411+ var lines []string
412+ // Spend a row on the above marker only when the first block still
413+ // gets a row afterwards: on a terminal this short the highlight
414+ // matters more than the hint that entries are hidden.
415+ if start > 0 && (unbounded || remaining > 1) {
416+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
417+ remaining--
418+ }
419+ for i := start; i < end; i++ {
420+ block := blocks[i]
421+ if !unbounded {
422+ if remaining <= 0 {
423+ break
424+ }
425+ if len(block) > remaining {
426+ block = block[:remaining]
274427 }
275428 }
429+ lines = append(lines, block...)
430+ remaining -= len(block)
431+ }
432+ if end < len(blocks) && (unbounded || remaining > 0) {
433+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(blocks)-end)))
276434 }
277- lines = append(lines, frameRule(th, width))
278435 return lines
279436 }
280437
@@ -289,27 +446,37 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
289446 title = d.title
290447 }
291448 lines := []string{frameHeader(th, title, width)}
292- if it.desc != "" {
449+ // Keep room for the hint, closing rule, and at least the selected
450+ // option's label before spending a row on the optional description.
451+ if it.desc != "" && (d.MaxRows <= 0 || d.MaxRows >= 5) {
293452 lines = append(lines, th.FG256(th.Muted, it.desc))
294453 }
295454 lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))
455+ blocks := make([][]string, len(it.options))
456+ heights := make([]int, len(it.options))
296457 for idx, opt := range it.options {
297458 marker := " "
298459 if idx == it.choice {
299460 marker = "✓ "
300461 }
301462 plain := " " + marker + opt.label
463+ block := make([]string, 0, 4)
302464 if idx == d.optionCursor {
303- lines = append(lines, th.PadHighlight(plain, width))
465+ block = append(block, th.PadHighlight(plain, width))
304466 } else {
305- lines = append(lines, plain)
467+ block = append(block, plain)
306468 }
307- if opt.desc != "" {
308- for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
309- lines = append(lines, th.FG256(th.Muted, desc))
310- }
469+ for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
470+ block = append(block, th.FG256(th.Muted, desc))
311471 }
472+ blocks[idx] = block
473+ heights[idx] = len(block)
312474 }
475+ // Same windowing as the item list: a theme list longer than the
476+ // terminal scrolls with the cursor instead of losing its head.
477+ budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
478+ start, end := d.windowBlocks(heights, d.optionCursor, budget)
479+ lines = append(lines, windowLines(th, blocks, start, end, budget)...)
313480 lines = append(lines, frameRule(th, width))
314481 return lines
315482 }
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "fmt"
4 "strings"5 "strings"
5 6
6 "github.com/mattn/go-runewidth"7 "github.com/mattn/go-runewidth"
@@ -18,6 +19,18 @@ type settingsDialog struct {
18 optionCursor int19 optionCursor int
19 parentItems []settingsItem20 parentItems []settingsItem
20 parentCursor int21 parentCursor int
22+
23+ // MaxRows caps how many rows the dialog renders. The host sets it
24+ // from the terminal height before each render; zero renders the
25+ // list in full. A list taller than the cap scrolls in a window
26+ // that follows the cursor so entries never disappear off-screen.
27+ MaxRows int
28+
29+ // viewTop is the index of the first entry drawn in the current
30+ // window. It survives across renders so the window only moves when
31+ // the cursor pushes past one of its edges, and is reset whenever
32+ // the dialog swaps between the item list and an option list.
33+ viewTop int
21 }34 }
22 35
23 type settingsItem struct {36 type settingsItem struct {
@@ -62,6 +75,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
62 d.optionCursor = 075 d.optionCursor = 0
63 d.parentItems = nil76 d.parentItems = nil
64 d.parentCursor = 077 d.parentCursor = 0
78+ d.viewTop = 0
65 d.active = true79 d.active = true
66 return true80 return true
67 }81 }
@@ -81,6 +95,7 @@ func (d *settingsDialog) OpenDirectOption(item settingsItem) bool {
81 }95 }
82 d.parentItems = nil96 d.parentItems = nil
83 d.parentCursor = 097 d.parentCursor = 0
98+ d.viewTop = 0
84 d.active = true99 d.active = true
85 return true100 return true
86 }101 }
@@ -90,6 +105,7 @@ func (d *settingsDialog) Close() {
90 d.selecting = false105 d.selecting = false
91 d.direct = false106 d.direct = false
92 d.parentItems = nil107 d.parentItems = nil
108+ d.viewTop = 0
93 }109 }
94 func (d *settingsDialog) Active() bool { return d != nil && d.active }110 func (d *settingsDialog) Active() bool { return d != nil && d.active }
95 111
@@ -119,6 +135,7 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
119 d.cursor = d.parentCursor135 d.cursor = d.parentCursor
120 d.parentItems = nil136 d.parentItems = nil
121 d.parentCursor = 0137 d.parentCursor = 0
138+ d.viewTop = 0
122 d.title = "settings"139 d.title = "settings"
123 return settingsAction{}140 return settingsAction{}
124 }141 }
@@ -151,6 +168,7 @@ func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
151 return settingsAction{Close: true}168 return settingsAction{Close: true}
152 }169 }
153 d.selecting = false170 d.selecting = false
171+ d.viewTop = 0
154 case tui.KeyEnter:172 case tui.KeyEnter:
155 return d.selectCurrentOption()173 return d.selectCurrentOption()
156 case tui.KeyRune:174 case tui.KeyRune:
@@ -188,6 +206,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
188 d.items = it.children206 d.items = it.children
189 d.cursor = 0207 d.cursor = 0
190 d.optionCursor = 0208 d.optionCursor = 0
209+ d.viewTop = 0
191 d.title = "settings: " + it.label210 d.title = "settings: " + it.label
192 return settingsAction{}211 return settingsAction{}
193 }212 }
@@ -197,6 +216,7 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
197 d.optionCursor = 0216 d.optionCursor = 0
198 }217 }
199 d.selecting = true218 d.selecting = true
219+ d.viewTop = 0
200 return settingsAction{}220 return settingsAction{}
201 }221 }
202 it.value = !it.value222 it.value = !it.value
@@ -220,6 +240,7 @@ func (d *settingsDialog) selectCurrentOption() settingsAction {
220 it.choice = d.optionCursor240 it.choice = d.optionCursor
221 d.items[d.cursor] = it241 d.items[d.cursor] = it
222 d.selecting = false242 d.selecting = false
243+ d.viewTop = 0
223 action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}244 action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
224 if d.direct {245 if d.direct {
225 d.Close()246 d.Close()
@@ -242,39 +263,175 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
242 } else {263 } else {
243 lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))264 lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:"))
244 }265 }
245- for i, it := range d.items {266+
246- box := "[ ]"267+ // Render every entry to its own block up front so the row window
247- if it.value {268+ // can count wrapped descriptions, then draw only the blocks that
248- box = "[✓]"269+ // fit the budget. Windowing keeps the cursor entry on screen: a
249- }270+ // list taller than the terminal scrolls instead of having its top
250- plain := " " + box + " " + it.label271+ // entries clipped off and unreachable.
251- if it.picker || len(it.children) > 0 {272+ blocks := make([][]string, len(d.items))
252- box = "[→]"273+ heights := make([]int, len(d.items))
253- plain = " " + box + " " + it.label274+ for i := range d.items {
254- } else if len(it.options) > 0 {275+ blocks[i] = d.itemBlock(th, width, i)
255- box = "[→]"276+ heights[i] = len(blocks[i])
256- if it.choice < 0 || it.choice >= len(it.options) {277+ }
257- it.choice = 0278+ budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
258- }279+ start, end := d.windowBlocks(heights, d.cursor, budget)
259- plain = " " + box + " " + it.label + ": " + it.options[it.choice].label280+ lines = append(lines, windowLines(th, blocks, start, end, budget)...)
260- }281+ lines = append(lines, frameRule(th, width))
261- if it.hint != "" {282+ return lines
262- plain += " " + th.FG256(th.Muted, "("+it.hint+")")283+}
284+
285+// itemBlock renders one entry as its label row followed by any wrapped
286+// description rows. Keeping both in one block lets the row window
287+// count the description and trim it when space runs out without losing
288+// the label row the cursor highlight sits on.
289+func (d *settingsDialog) itemBlock(th tui.Theme, width, idx int) []string {
290+ it := d.items[idx]
291+ box := "[ ]"
292+ if it.value {
293+ box = "[✓]"
294+ }
295+ plain := " " + box + " " + it.label
296+ if it.picker || len(it.children) > 0 {
297+ box = "[→]"
298+ plain = " " + box + " " + it.label
299+ } else if len(it.options) > 0 {
300+ box = "[→]"
301+ if it.choice < 0 || it.choice >= len(it.options) {
302+ it.choice = 0
263 }303 }
264- if it.disabled {304+ plain = " " + box + " " + it.label + ": " + it.options[it.choice].label
265- lines = append(lines, th.FG256(th.Muted, plain))305+ }
266- } else if i == d.cursor {306+ if it.hint != "" {
267- lines = append(lines, th.PadHighlight(plain, width))307+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
268- } else {308+ }
269- lines = append(lines, plain)309+ line := plain
310+ switch {
311+ case it.disabled:
312+ line = th.FG256(th.Muted, plain)
313+ case idx == d.cursor:
314+ line = th.PadHighlight(plain, width)
315+ }
316+ block := []string{line}
317+ for _, desc := range wrapSettingDescription(it.desc, width, 6) {
318+ block = append(block, th.FG256(th.Muted, desc))
319+ }
320+ return block
321+}
322+
323+// rowBudget returns the rows left for the window after the fixed rows
324+// around it (frame header, hint, closing rule) are counted against
325+// MaxRows. Zero means unbounded.
326+func (d *settingsDialog) rowBudget(fixed int) int {
327+ if d.MaxRows <= 0 {
328+ return 0
329+ }
330+ if budget := d.MaxRows - fixed; budget > 0 {
331+ return budget
332+ }
333+ return 1
334+}
335+
336+// windowBlocks returns the [start, end) range of entry blocks to draw
337+// so the cursor entry stays visible. Heights are the rendered row
338+// counts per entry, budget is the rows available for the blocks plus
339+// the "more above/below" markers, and zero means windowing is off.
340+func (d *settingsDialog) windowBlocks(heights []int, cursor, budget int) (start, end int) {
341+ total := len(heights)
342+ if total == 0 {
343+ return 0, 0
344+ }
345+ if budget <= 0 {
346+ d.viewTop = 0
347+ return 0, total
348+ }
349+ if cursor < 0 {
350+ cursor = 0
351+ }
352+ if cursor >= total {
353+ cursor = total - 1
354+ }
355+ rows := 0
356+ for _, h := range heights {
357+ rows += h
358+ }
359+ if rows <= budget {
360+ d.viewTop = 0
361+ return 0, total
362+ }
363+ // Entries are hidden above and/or below, so hold back a row for
364+ // the markers. On a terminal too short for the cursor entry plus
365+ // markers, drop the reservation: the highlighted row matters more
366+ // than the hint that more entries exist.
367+ inner := budget - 2
368+ if inner < heights[cursor] {
369+ inner = budget
370+ }
371+ return d.anchorBlocks(heights, cursor, inner)
372+}
373+
374+// anchorBlocks returns the block range of combined height <= budget
375+// that keeps the cursor entry visible. The window extends downward
376+// from the persistent viewTop; when the cursor moves past either edge
377+// the window re-anchors so the cursor entry becomes the first or last
378+// visible one. viewTop is updated for the next render so the window
379+// stays put while the cursor moves inside it.
380+func (d *settingsDialog) anchorBlocks(heights []int, cursor, budget int) (start, end int) {
381+ total := len(heights)
382+ start = d.viewTop
383+ if start > cursor || start >= total {
384+ start = cursor
385+ }
386+ used := 0
387+ for end = start; end < total && (end == start || used+heights[end] <= budget); end++ {
388+ used += heights[end]
389+ }
390+ if cursor >= end {
391+ // The cursor fell past the bottom edge: re-anchor on it and
392+ // fill the remaining budget with entries above.
393+ start, end = cursor, cursor+1
394+ used = heights[cursor]
395+ for start > 0 && used+heights[start-1] <= budget {
396+ start--
397+ used += heights[start]
270 }398 }
271- if it.desc != "" {399+ }
272- for _, desc := range wrapSettingDescription(it.desc, width, 6) {400+ d.viewTop = start
273- lines = append(lines, th.FG256(th.Muted, desc))401+ return start, end
402+}
403+
404+// windowLines renders the [start, end) slice of entry blocks, framed by
405+// muted "more above/below" markers when entries are hidden on either
406+// side. Blocks are trimmed so the result never uses more than budget
407+// rows; budget <= 0 means unbounded.
408+func windowLines(th tui.Theme, blocks [][]string, start, end, budget int) []string {
409+ unbounded := budget <= 0
410+ remaining := budget
411+ var lines []string
412+ // Spend a row on the above marker only when the first block still
413+ // gets a row afterwards: on a terminal this short the highlight
414+ // matters more than the hint that entries are hidden.
415+ if start > 0 && (unbounded || remaining > 1) {
416+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
417+ remaining--
418+ }
419+ for i := start; i < end; i++ {
420+ block := blocks[i]
421+ if !unbounded {
422+ if remaining <= 0 {
423+ break
424+ }
425+ if len(block) > remaining {
426+ block = block[:remaining]
274 }427 }
275 }428 }
429+ lines = append(lines, block...)
430+ remaining -= len(block)
431+ }
432+ if end < len(blocks) && (unbounded || remaining > 0) {
433+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(blocks)-end)))
276 }434 }
277- lines = append(lines, frameRule(th, width))
278 return lines435 return lines
279 }436 }
280 437
@@ -289,27 +446,37 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
289 title = d.title446 title = d.title
290 }447 }
291 lines := []string{frameHeader(th, title, width)}448 lines := []string{frameHeader(th, title, width)}
292- if it.desc != "" {449+ // Keep room for the hint, closing rule, and at least the selected
450+ // option's label before spending a row on the optional description.
451+ if it.desc != "" && (d.MaxRows <= 0 || d.MaxRows >= 5) {
293 lines = append(lines, th.FG256(th.Muted, it.desc))452 lines = append(lines, th.FG256(th.Muted, it.desc))
294 }453 }
295 lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))454 lines = append(lines, th.FG256(th.Muted, "select with enter/space, esc to go back:"))
455+ blocks := make([][]string, len(it.options))
456+ heights := make([]int, len(it.options))
296 for idx, opt := range it.options {457 for idx, opt := range it.options {
297 marker := " "458 marker := " "
298 if idx == it.choice {459 if idx == it.choice {
299 marker = "✓ "460 marker = "✓ "
300 }461 }
301 plain := " " + marker + opt.label462 plain := " " + marker + opt.label
463+ block := make([]string, 0, 4)
302 if idx == d.optionCursor {464 if idx == d.optionCursor {
303- lines = append(lines, th.PadHighlight(plain, width))465+ block = append(block, th.PadHighlight(plain, width))
304 } else {466 } else {
305- lines = append(lines, plain)467+ block = append(block, plain)
306 }468 }
307- if opt.desc != "" {469+ for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
308- for _, desc := range wrapSettingDescription(opt.desc, width, 6) {470+ block = append(block, th.FG256(th.Muted, desc))
309- lines = append(lines, th.FG256(th.Muted, desc))
310- }
311 }471 }
472+ blocks[idx] = block
473+ heights[idx] = len(block)
312 }474 }
475+ // Same windowing as the item list: a theme list longer than the
476+ // terminal scrolls with the cursor instead of losing its head.
477+ budget := d.rowBudget(len(lines) + 1) // one row for the closing rule
478+ start, end := d.windowBlocks(heights, d.optionCursor, budget)
479+ lines = append(lines, windowLines(th, blocks, start, end, budget)...)
313 lines = append(lines, frameRule(th, width))480 lines = append(lines, frameRule(th, width))
314 return lines481 return lines
315 }482 }
A packages/agent/modes/settings_dialog_test.go +231 -0
@@ -0,0 +1,231 @@
1+package modes
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/tui"
12+)
13+
14+// settingsTestItems builds a list long enough to overflow a short
15+// terminal. Every entry carries a description that wraps, so an entry
16+// costs more than one row and the window has to account for it.
17+func settingsTestItems(n int) []settingsItem {
18+ items := make([]settingsItem, 0, n)
19+ for i := 0; i < n; i++ {
20+ items = append(items, settingsItem{
21+ key: fmt.Sprintf("item_%02d", i),
22+ label: fmt.Sprintf("entry %02d", i),
23+ desc: "description text that wraps onto several rows at the widths these tests render so every entry costs more than a single line",
24+ })
25+ }
26+ return items
27+}
28+
29+func renderedPlain(lines []string) string { return stripANSIBytes(strings.Join(lines, "\n")) }
30+
31+func TestSettingsDialogWindowsListToMaxRows(t *testing.T) {
32+ d := newSettingsDialog()
33+ d.Open(settingsTestItems(20))
34+ d.MaxRows = 12
35+
36+ lines := d.Render(tui.Dark, 80)
37+ if len(lines) > d.MaxRows {
38+ t.Fatalf("rendered %d rows, want <= %d", len(lines), d.MaxRows)
39+ }
40+ text := renderedPlain(lines)
41+ for _, want := range []string{"entry 00", "entry 01", "more below"} {
42+ if !strings.Contains(text, want) {
43+ t.Fatalf("top of window missing %q:\n%s", want, text)
44+ }
45+ }
46+ if strings.Contains(text, "entry 19") {
47+ t.Fatalf("window rendered the far tail of the list:\n%s", text)
48+ }
49+}
50+
51+func TestSettingsDialogWindowFollowsCursorToBothEnds(t *testing.T) {
52+ d := newSettingsDialog()
53+ d.Open(settingsTestItems(20))
54+ d.MaxRows = 10
55+
56+ for i := 1; i < 20; i++ {
57+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
58+ lines := d.Render(tui.Dark, 80)
59+ if len(lines) > d.MaxRows {
60+ t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
61+ }
62+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
63+ t.Fatalf("cursor entry %02d scrolled out of view:\n%s", i, text)
64+ }
65+ }
66+ top := d.Render(tui.Dark, 80)
67+ if text := renderedPlain(top); !strings.Contains(text, "more above") {
68+ t.Fatalf("bottom of list missing the above marker:\n%s", text)
69+ }
70+
71+ for i := 18; i >= 0; i-- {
72+ d.HandleKey(tui.Key{Kind: tui.KeyUp})
73+ lines := d.Render(tui.Dark, 80)
74+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
75+ t.Fatalf("cursor entry %02d scrolled out of view on the way back:\n%s", i, text)
76+ }
77+ }
78+ if text := renderedPlain(d.Render(tui.Dark, 80)); !strings.Contains(text, "entry 00") {
79+ t.Fatalf("first entry not visible after scrolling back up:\n%s", text)
80+ }
81+}
82+
83+func TestSettingsDialogOptionsWindowFollowsCursor(t *testing.T) {
84+ item := settingsItem{key: "theme", label: "color theme"}
85+ for i := 0; i < 30; i++ {
86+ item.options = append(item.options, settingsOption{
87+ value: fmt.Sprintf("opt_%02d", i),
88+ label: fmt.Sprintf("option %02d", i),
89+ desc: "option description that also wraps onto several rows at the widths these tests render",
90+ })
91+ }
92+ d := newSettingsDialog()
93+ d.OpenDirectOption(item)
94+ d.MaxRows = 14
95+
96+ for i := 1; i < 30; i++ {
97+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
98+ lines := d.Render(tui.Dark, 80)
99+ if len(lines) > d.MaxRows {
100+ t.Fatalf("option cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
101+ }
102+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("option %02d", i)) {
103+ t.Fatalf("option %02d scrolled out of view:\n%s", i, text)
104+ }
105+ }
106+ last := d.Render(tui.Dark, 80)
107+ if text := renderedPlain(last); !strings.Contains(text, "more above") || !strings.Contains(text, "option 29") {
108+ t.Fatalf("bottom of option list not windowed on the cursor:\n%s", text)
109+ }
110+}
111+
112+func TestSettingsDialogKeepsCursorRowOnTinyTerminal(t *testing.T) {
113+ d := newSettingsDialog()
114+ d.Open(settingsTestItems(20))
115+ // Four rows is the dialog's floor: header, hint, one row of list,
116+ // rule. The cursor entry must still be the row that survives.
117+ d.MaxRows = 4
118+ for i := 0; i < 20; i++ {
119+ lines := d.Render(tui.Dark, 80)
120+ if len(lines) > d.MaxRows {
121+ t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
122+ }
123+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
124+ t.Fatalf("cursor entry %02d missing at the row floor:\n%s", i, text)
125+ }
126+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
127+ }
128+}
129+
130+func TestSettingsOptionPickerFitsShortTerminal(t *testing.T) {
131+ for _, rows := range []int{12, 14, 16, 17, 24} {
132+ t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
133+ term := &shortTestTerminal{cols: 80, rows: rows}
134+ i := NewInteractive(InteractiveConfig{Terminal: term})
135+ i.settingsDialog.OpenDirectOption(settingsItem{
136+ label: "reasoning level", desc: "reasoning depth for reasoning-capable models",
137+ options: []settingsOption{{label: "off"}, {label: "low"}},
138+ })
139+ i.redraw()
140+
141+ lines := i.settingsDialog.Render(i.cfg.Theme, 80)
142+ if len(lines) > i.settingsDialog.MaxRows {
143+ t.Fatalf("option picker rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
144+ }
145+ text := renderedPlain(lines)
146+ if !strings.Contains(text, "off") {
147+ t.Fatalf("selected option missing from window:\n%s", text)
148+ }
149+ if i.settingsDialog.MaxRows >= 5 && !strings.Contains(text, "reasoning depth") {
150+ t.Fatalf("description missing when it fits:\n%s", text)
151+ }
152+ written := strings.Count(term.String(), "\r\n") + 1
153+ band := written - len(i.cachedChatLocked(80))
154+ if band > rows-1 {
155+ t.Fatalf("bottom band is %d rows for a %d-row terminal", band, rows)
156+ }
157+ })
158+ }
159+}
160+
161+func TestSettingsDialogRendersEverythingWithoutMaxRows(t *testing.T) {
162+ d := newSettingsDialog()
163+ d.Open(settingsTestItems(20))
164+
165+ text := renderedPlain(d.Render(tui.Dark, 80))
166+ for _, want := range []string{"entry 00", "entry 19"} {
167+ if !strings.Contains(text, want) {
168+ t.Fatalf("unbounded render missing %q:\n%s", want, text)
169+ }
170+ }
171+ if strings.Contains(text, "more above") || strings.Contains(text, "more below") {
172+ t.Fatalf("unbounded render drew window markers:\n%s", text)
173+ }
174+}
175+
176+// shortTestTerminal is a Terminal with a configurable size so the
177+// layout can be exercised at heights a real window would report.
178+type shortTestTerminal struct {
179+ bytes.Buffer
180+ cols, rows int
181+}
182+
183+func (t *shortTestTerminal) Size() (int, int) { return t.cols, t.rows }
184+func (t *shortTestTerminal) OnResize(func()) {}
185+func (t *shortTestTerminal) SetNonblock(bool) error { return nil }
186+func (t *shortTestTerminal) ReadByte() (byte, error) {
187+ return 0, io.EOF
188+}
189+func (t *shortTestTerminal) EnterRaw() (func() error, error) {
190+ return func() error { return nil }, nil
191+}
192+func (t *shortTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
193+ return 0, false, io.EOF
194+}
195+
196+func TestSettingsDialogIsCappedToShortTerminal(t *testing.T) {
197+ for _, rows := range []int{12, 14, 16, 24, 40, 60} {
198+ t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
199+ term := &shortTestTerminal{cols: 80, rows: rows}
200+ i := NewInteractive(InteractiveConfig{Terminal: term})
201+ i.openSettingsDialog()
202+ if !i.settingsDialog.Active() {
203+ t.Fatal("settings dialog did not open")
204+ }
205+ i.redraw()
206+
207+ if i.settingsDialog.MaxRows <= 0 || i.settingsDialog.MaxRows >= term.rows {
208+ t.Fatalf("dialog cap = %d rows for a %d-row terminal", i.settingsDialog.MaxRows, term.rows)
209+ }
210+ lines := i.settingsDialog.Render(i.cfg.Theme, 80)
211+ if len(lines) > i.settingsDialog.MaxRows {
212+ t.Fatalf("dialog rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
213+ }
214+ text := renderedPlain(lines)
215+ if !strings.Contains(text, "render images when supported") {
216+ t.Fatalf("first settings entry missing from the window:\n%s", text)
217+ }
218+
219+ // The band the dialog draws into must fit above the
220+ // terminal's bottom edge: written lines are the chat, the
221+ // bottom band, and the renderer's trailing margin row. A
222+ // taller band scrolls the top of the dialog out of reach
223+ // again.
224+ written := strings.Count(term.String(), "\r\n") + 1
225+ band := written - len(i.cachedChatLocked(80))
226+ if band > term.rows-1 {
227+ t.Fatalf("bottom band is %d rows for a %d-row terminal; dialog would be clipped", band, term.rows)
228+ }
229+ })
230+ }
231+}
@@ -0,0 +1,231 @@
1+package modes
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/tui"
12+)
13+
14+// settingsTestItems builds a list long enough to overflow a short
15+// terminal. Every entry carries a description that wraps, so an entry
16+// costs more than one row and the window has to account for it.
17+func settingsTestItems(n int) []settingsItem {
18+ items := make([]settingsItem, 0, n)
19+ for i := 0; i < n; i++ {
20+ items = append(items, settingsItem{
21+ key: fmt.Sprintf("item_%02d", i),
22+ label: fmt.Sprintf("entry %02d", i),
23+ desc: "description text that wraps onto several rows at the widths these tests render so every entry costs more than a single line",
24+ })
25+ }
26+ return items
27+}
28+
29+func renderedPlain(lines []string) string { return stripANSIBytes(strings.Join(lines, "\n")) }
30+
31+func TestSettingsDialogWindowsListToMaxRows(t *testing.T) {
32+ d := newSettingsDialog()
33+ d.Open(settingsTestItems(20))
34+ d.MaxRows = 12
35+
36+ lines := d.Render(tui.Dark, 80)
37+ if len(lines) > d.MaxRows {
38+ t.Fatalf("rendered %d rows, want <= %d", len(lines), d.MaxRows)
39+ }
40+ text := renderedPlain(lines)
41+ for _, want := range []string{"entry 00", "entry 01", "more below"} {
42+ if !strings.Contains(text, want) {
43+ t.Fatalf("top of window missing %q:\n%s", want, text)
44+ }
45+ }
46+ if strings.Contains(text, "entry 19") {
47+ t.Fatalf("window rendered the far tail of the list:\n%s", text)
48+ }
49+}
50+
51+func TestSettingsDialogWindowFollowsCursorToBothEnds(t *testing.T) {
52+ d := newSettingsDialog()
53+ d.Open(settingsTestItems(20))
54+ d.MaxRows = 10
55+
56+ for i := 1; i < 20; i++ {
57+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
58+ lines := d.Render(tui.Dark, 80)
59+ if len(lines) > d.MaxRows {
60+ t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
61+ }
62+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
63+ t.Fatalf("cursor entry %02d scrolled out of view:\n%s", i, text)
64+ }
65+ }
66+ top := d.Render(tui.Dark, 80)
67+ if text := renderedPlain(top); !strings.Contains(text, "more above") {
68+ t.Fatalf("bottom of list missing the above marker:\n%s", text)
69+ }
70+
71+ for i := 18; i >= 0; i-- {
72+ d.HandleKey(tui.Key{Kind: tui.KeyUp})
73+ lines := d.Render(tui.Dark, 80)
74+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
75+ t.Fatalf("cursor entry %02d scrolled out of view on the way back:\n%s", i, text)
76+ }
77+ }
78+ if text := renderedPlain(d.Render(tui.Dark, 80)); !strings.Contains(text, "entry 00") {
79+ t.Fatalf("first entry not visible after scrolling back up:\n%s", text)
80+ }
81+}
82+
83+func TestSettingsDialogOptionsWindowFollowsCursor(t *testing.T) {
84+ item := settingsItem{key: "theme", label: "color theme"}
85+ for i := 0; i < 30; i++ {
86+ item.options = append(item.options, settingsOption{
87+ value: fmt.Sprintf("opt_%02d", i),
88+ label: fmt.Sprintf("option %02d", i),
89+ desc: "option description that also wraps onto several rows at the widths these tests render",
90+ })
91+ }
92+ d := newSettingsDialog()
93+ d.OpenDirectOption(item)
94+ d.MaxRows = 14
95+
96+ for i := 1; i < 30; i++ {
97+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
98+ lines := d.Render(tui.Dark, 80)
99+ if len(lines) > d.MaxRows {
100+ t.Fatalf("option cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
101+ }
102+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("option %02d", i)) {
103+ t.Fatalf("option %02d scrolled out of view:\n%s", i, text)
104+ }
105+ }
106+ last := d.Render(tui.Dark, 80)
107+ if text := renderedPlain(last); !strings.Contains(text, "more above") || !strings.Contains(text, "option 29") {
108+ t.Fatalf("bottom of option list not windowed on the cursor:\n%s", text)
109+ }
110+}
111+
112+func TestSettingsDialogKeepsCursorRowOnTinyTerminal(t *testing.T) {
113+ d := newSettingsDialog()
114+ d.Open(settingsTestItems(20))
115+ // Four rows is the dialog's floor: header, hint, one row of list,
116+ // rule. The cursor entry must still be the row that survives.
117+ d.MaxRows = 4
118+ for i := 0; i < 20; i++ {
119+ lines := d.Render(tui.Dark, 80)
120+ if len(lines) > d.MaxRows {
121+ t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
122+ }
123+ if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
124+ t.Fatalf("cursor entry %02d missing at the row floor:\n%s", i, text)
125+ }
126+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
127+ }
128+}
129+
130+func TestSettingsOptionPickerFitsShortTerminal(t *testing.T) {
131+ for _, rows := range []int{12, 14, 16, 17, 24} {
132+ t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
133+ term := &shortTestTerminal{cols: 80, rows: rows}
134+ i := NewInteractive(InteractiveConfig{Terminal: term})
135+ i.settingsDialog.OpenDirectOption(settingsItem{
136+ label: "reasoning level", desc: "reasoning depth for reasoning-capable models",
137+ options: []settingsOption{{label: "off"}, {label: "low"}},
138+ })
139+ i.redraw()
140+
141+ lines := i.settingsDialog.Render(i.cfg.Theme, 80)
142+ if len(lines) > i.settingsDialog.MaxRows {
143+ t.Fatalf("option picker rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
144+ }
145+ text := renderedPlain(lines)
146+ if !strings.Contains(text, "off") {
147+ t.Fatalf("selected option missing from window:\n%s", text)
148+ }
149+ if i.settingsDialog.MaxRows >= 5 && !strings.Contains(text, "reasoning depth") {
150+ t.Fatalf("description missing when it fits:\n%s", text)
151+ }
152+ written := strings.Count(term.String(), "\r\n") + 1
153+ band := written - len(i.cachedChatLocked(80))
154+ if band > rows-1 {
155+ t.Fatalf("bottom band is %d rows for a %d-row terminal", band, rows)
156+ }
157+ })
158+ }
159+}
160+
161+func TestSettingsDialogRendersEverythingWithoutMaxRows(t *testing.T) {
162+ d := newSettingsDialog()
163+ d.Open(settingsTestItems(20))
164+
165+ text := renderedPlain(d.Render(tui.Dark, 80))
166+ for _, want := range []string{"entry 00", "entry 19"} {
167+ if !strings.Contains(text, want) {
168+ t.Fatalf("unbounded render missing %q:\n%s", want, text)
169+ }
170+ }
171+ if strings.Contains(text, "more above") || strings.Contains(text, "more below") {
172+ t.Fatalf("unbounded render drew window markers:\n%s", text)
173+ }
174+}
175+
176+// shortTestTerminal is a Terminal with a configurable size so the
177+// layout can be exercised at heights a real window would report.
178+type shortTestTerminal struct {
179+ bytes.Buffer
180+ cols, rows int
181+}
182+
183+func (t *shortTestTerminal) Size() (int, int) { return t.cols, t.rows }
184+func (t *shortTestTerminal) OnResize(func()) {}
185+func (t *shortTestTerminal) SetNonblock(bool) error { return nil }
186+func (t *shortTestTerminal) ReadByte() (byte, error) {
187+ return 0, io.EOF
188+}
189+func (t *shortTestTerminal) EnterRaw() (func() error, error) {
190+ return func() error { return nil }, nil
191+}
192+func (t *shortTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
193+ return 0, false, io.EOF
194+}
195+
196+func TestSettingsDialogIsCappedToShortTerminal(t *testing.T) {
197+ for _, rows := range []int{12, 14, 16, 24, 40, 60} {
198+ t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
199+ term := &shortTestTerminal{cols: 80, rows: rows}
200+ i := NewInteractive(InteractiveConfig{Terminal: term})
201+ i.openSettingsDialog()
202+ if !i.settingsDialog.Active() {
203+ t.Fatal("settings dialog did not open")
204+ }
205+ i.redraw()
206+
207+ if i.settingsDialog.MaxRows <= 0 || i.settingsDialog.MaxRows >= term.rows {
208+ t.Fatalf("dialog cap = %d rows for a %d-row terminal", i.settingsDialog.MaxRows, term.rows)
209+ }
210+ lines := i.settingsDialog.Render(i.cfg.Theme, 80)
211+ if len(lines) > i.settingsDialog.MaxRows {
212+ t.Fatalf("dialog rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
213+ }
214+ text := renderedPlain(lines)
215+ if !strings.Contains(text, "render images when supported") {
216+ t.Fatalf("first settings entry missing from the window:\n%s", text)
217+ }
218+
219+ // The band the dialog draws into must fit above the
220+ // terminal's bottom edge: written lines are the chat, the
221+ // bottom band, and the renderer's trailing margin row. A
222+ // taller band scrolls the top of the dialog out of reach
223+ // again.
224+ written := strings.Count(term.String(), "\r\n") + 1
225+ band := written - len(i.cachedChatLocked(80))
226+ if band > term.rows-1 {
227+ t.Fatalf("bottom band is %d rows for a %d-row terminal; dialog would be clipped", band, term.rows)
228+ }
229+ })
230+ }
231+}

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