▍ humdrum codex / glint v1.0.2

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])
+		}
+	}
+}