fix: truncate picker labels so long paths don't wrap and scroll the query field off-screen
9ffef83d0e26f66ddee90e6b5b8700bc877612e7
humdrum <me@humdrum.me> · 2026-06-28 13:48
parent 7ef73794
fix: truncate picker labels so long paths don't wrap and scroll the query field off-screen Long vault-relative paths (e.g. deep ARCHER/Claude meeting notes) exceeded the list column and wrapped to two terminal rows. With a viewport full of notes, the extra wrapped rows pushed the whole list down past the screen height, scrolling the 'note ›' query field off the top. Truncate each label to the column width (tail-kept behind a leading ellipsis) so every match is exactly one row; add listColWidth() and a regression test asserting no rendered line exceeds the terminal width and the query stays on line 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 files changed
internal/picker/picker.go +34 −1
@@ -183,6 +183,38 @@ }
return m.width*6/10 - 2 // minus border
}
+// listColWidth is the width available to the match list: the full terminal when
+// the preview pane is hidden, else the leftover column beside it (matching the
+// width used to render listCol in View).
+func (m *Model) listColWidth() int {
+ if m.previewWidth() <= 0 {
+ return m.width
+ }
+ w := m.width - m.previewWidth() - 3
+ if w < 1 {
+ w = 1
+ }
+ return w
+}
+
+// truncateTail shortens s to at most max runes, keeping the tail (the filename
+// end is the useful part of a long path) behind a leading ellipsis. Labels must
+// fit one row: a wrapped label inflates the list past the viewport and scrolls
+// the query field off the top.
+func truncateTail(s string, max int) string {
+ if max <= 0 {
+ return ""
+ }
+ r := []rune(s)
+ if len(r) <= max {
+ return s
+ }
+ if max == 1 {
+ return "…"
+ }
+ return "…" + string(r[len(r)-(max-1):])
+}
+
// renderPreview loads the highlighted file into the preview viewport.
func (m *Model) renderPreview() {
if m.previewWidth() <= 0 {
@@ -230,8 +262,9 @@ rows := m.height - 2
if rows < 1 {
rows = 1
}
+ labelW := m.listColWidth() - 2 // leave room for the "▸ "/" " prefix
for i := 0; i < len(m.matches) && i < rows; i++ {
- label := m.label(m.matches[i])
+ label := truncateTail(m.label(m.matches[i]), labelW)
if i == m.sel {
b.WriteString(pointer.Render("▸ "))
b.WriteString(selStyle.Render(label))
internal/picker/wrap_overflow_test.go +69 −0
@@ -0,0 +1,69 @@
+package picker
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "glint/internal/theme"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Long vault-relative paths must not wrap in the list column: a wrapped label
+// inflates the rendered height past the viewport and scrolls the query field
+// (top line) off-screen. Every rendered line must fit the terminal width.
+func TestListLabelsDoNotWrapOrOverflow(t *testing.T) {
+ root := t.TempDir()
+ names := []string{
+ "short.md",
+ "ARCHER/Meetings/2026-06-18 ARCHER ABE Team Meeting Review.md",
+ "Claude/Issues/humdrum/task-003 - Schedule-b2-backup.sh-and-b2-backup-ssd.sh-as-daily-launchd-jobs.md",
+ "ARCHER/Working Files/Active-Member (we have a membership field).md",
+ }
+ // Plus enough files to overfill the viewport — the real-world trigger is a
+ // vault of hundreds of notes, where a single wrapped label pushes the query
+ // field off the top.
+ for i := 0; i < 80; i++ {
+ names = append(names, filepath.Join("ARCHER/Meetings",
+ "2026-06-18 ARCHER ABE Team Meeting Review long filler "+strings.Repeat("x", 30)+".md"))
+ }
+ for i, n := range names {
+ // de-dup the filler names so they are distinct files
+ if i >= 4 {
+ n = strings.Replace(n, "filler ", "filler"+string(rune('a'+i%26))+" ", 1)
+ }
+ p := filepath.Join(root, n)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte("# x\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ for _, dims := range [][2]int{{120, 30}, {80, 24}, {50, 20}} {
+ m, err := New(root, theme.FlexokiDark(), "", "dark")
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.SetSize(dims[0], dims[1]-1) // app gives the picker height-1
+
+ out := m.View()
+ lines := strings.Split(out, "\n")
+
+ if !strings.Contains(lines[0], "note") {
+ t.Errorf("dims=%v: query field not on the first line; got %q", dims, lines[0])
+ }
+ for i, ln := range lines {
+ if w := lipgloss.Width(ln); w > dims[0] {
+ t.Errorf("dims=%v: line %d width %d exceeds terminal width %d (will wrap): %q",
+ dims, i, w, dims[0], ln)
+ }
+ }
+ if len(lines) > dims[1] {
+ t.Errorf("dims=%v: picker view has %d lines, exceeds terminal height %d", dims, len(lines), dims[1])
+ }
+ }
+}