From 0c7573ebdfc3ef75769cfce3acf243fa76dfc9dd Mon Sep 17 00:00:00 2001 From: Or Yaacov Date: Fri, 27 Mar 2026 20:20:59 +0300 Subject: [PATCH 1/2] feat: add overlay dropdown infrastructure for floating UI menus Add ANSI-aware overlay helpers (overlayDropdown, truncateVisible, sliceVisibleFrom) that paint a multi-line string on top of a rendered TUI screen at a specific row/col, giving a z-index effect. Also adds renderSuggestionsDropdown which renders path suggestions as a bordered floating menu with a distinct background color and keybinding footer. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/ui/newdialog.go | 197 +++++++++++++++++++++++++++++++++- internal/ui/newdialog_test.go | 67 ++++++++++++ 2 files changed, 262 insertions(+), 2 deletions(-) diff --git a/internal/ui/newdialog.go b/internal/ui/newdialog.go index 31e8cd22f..03b166380 100644 --- a/internal/ui/newdialog.go +++ b/internal/ui/newdialog.go @@ -16,6 +16,118 @@ import ( "github.com/asheshgoplani/agent-deck/internal/statedb" ) +// overlayDropdown paints `overlay` on top of `base` starting at the given +// row and column (0-indexed). Lines of the overlay replace the characters +// underneath while preserving the rest of each base line. This gives a +// "z-index" effect for floating dropdowns. +func overlayDropdown(base string, overlay string, row, col int) string { + baseLines := strings.Split(base, "\n") + overLines := strings.Split(overlay, "\n") + + for i, ol := range overLines { + targetRow := row + i + if targetRow < 0 || targetRow >= len(baseLines) { + continue + } + bl := baseLines[targetRow] + blWidth := lipgloss.Width(bl) + + // Build: [left padding] [overlay line] [right remainder] + var result strings.Builder + + if col > 0 { + if col <= blWidth { + // Truncate base line to col visible chars + result.WriteString(truncateVisible(bl, col)) + } else { + // Base line is shorter than col; pad with spaces + result.WriteString(bl) + result.WriteString(strings.Repeat(" ", col-blWidth)) + } + } + + result.WriteString(ol) + + // Append remaining base chars after the overlay + olWidth := lipgloss.Width(ol) + afterCol := col + olWidth + if afterCol < blWidth { + result.WriteString(sliceVisibleFrom(bl, afterCol)) + } + + baseLines[targetRow] = result.String() + } + + return strings.Join(baseLines, "\n") +} + +// truncateVisible returns the prefix of s that spans exactly n visible columns. +// ANSI escape sequences are preserved for any characters included. +func truncateVisible(s string, n int) string { + if n <= 0 { + return "" + } + visible := 0 + inEsc := false + var buf strings.Builder + for _, r := range s { + if r == '\x1b' { + inEsc = true + buf.WriteRune(r) + continue + } + if inEsc { + buf.WriteRune(r) + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || r == '~' || r == '\\' { + inEsc = false + } + continue + } + if visible >= n { + break + } + buf.WriteRune(r) + visible++ + } + return buf.String() +} + +// sliceVisibleFrom returns the suffix of s starting from visible column n. +// ANSI sequences attached to skipped characters are dropped. +func sliceVisibleFrom(s string, n int) string { + if n <= 0 { + return s + } + visible := 0 + inEsc := false + for i, r := range s { + if r == '\x1b' { + inEsc = true + continue + } + if inEsc { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || r == '~' || r == '\\' { + inEsc = false + } + continue + } + if visible >= n { + return s[i:] + } + visible++ + } + return "" +} + +// dropdownMenuBg returns a slightly elevated background color for floating menus. +// Dark theme: one step brighter than Surface. Light theme: one step darker. +func dropdownMenuBg() lipgloss.Color { + if currentTheme == ThemeLight { + return lipgloss.Color("#dcdde2") + } + return lipgloss.Color("#292e42") +} + // focusTarget identifies a focusable element in the new session dialog. type focusTarget int @@ -70,8 +182,9 @@ type NewDialog struct { inheritedExpanded bool // whether the inherited settings section is expanded. inheritedSettings []settingDisplay // non-default Docker config values to display. // Inline validation error displayed inside the dialog. - validationErr string - pathCycler session.CompletionCycler // Path autocomplete state. + validationErr string + pathCycler session.CompletionCycler // Path autocomplete state. + suggestionsLineOffset int // Content line where suggestions overlay should appear. // Multi-repo mode. multiRepoEnabled bool multiRepoPaths []string // All paths when multi-repo is active. @@ -1700,3 +1813,83 @@ func (d *NewDialog) View() string { dialog, ) } + +// renderSuggestionsDropdown renders the path suggestions as a bordered floating +// menu for overlay positioning. Returns empty string if no suggestions to show. +func (d *NewDialog) renderSuggestionsDropdown() string { + cur := d.currentTarget() + + // Single-path mode: show when path focused + showSingle := !d.multiRepoEnabled && cur == focusPath && len(d.pathSuggestions) > 0 + // Multi-repo mode: show when editing a path entry + showMulti := d.multiRepoEnabled && cur == focusMultiRepo && d.multiRepoEditing && len(d.pathSuggestions) > 0 + + if !showSingle && !showMulti { + return "" + } + + menuBg := dropdownMenuBg() + suggestionStyle := lipgloss.NewStyle().Foreground(ColorComment).Background(menuBg) + selectedStyle := lipgloss.NewStyle().Foreground(ColorCyan).Bold(true).Background(menuBg) + + maxShow := 5 + total := len(d.pathSuggestions) + startIdx := 0 + endIdx := total + if total > maxShow { + startIdx = d.pathSuggestionCursor - maxShow/2 + if startIdx < 0 { + startIdx = 0 + } + endIdx = startIdx + maxShow + if endIdx > total { + endIdx = total + startIdx = endIdx - maxShow + } + } + + var b strings.Builder + + if startIdx > 0 { + b.WriteString(suggestionStyle.Render(fmt.Sprintf(" ↑ %d more above", startIdx))) + b.WriteString("\n") + } + + for i := startIdx; i < endIdx; i++ { + if i > startIdx { + b.WriteString("\n") + } + style := suggestionStyle + prefix := " " + if i == d.pathSuggestionCursor { + style = selectedStyle + prefix = "▶ " + } + b.WriteString(style.Render(prefix + d.pathSuggestions[i])) + } + + if endIdx < total { + b.WriteString("\n") + b.WriteString(suggestionStyle.Render(fmt.Sprintf(" ↓ %d more below", total-endIdx))) + } + + // Footer with keybinding hints + var footerText string + if len(d.pathSuggestions) < len(d.allPathSuggestions) { + footerText = fmt.Sprintf(" %d/%d matching │ ^N/^P cycle │ Tab accept ", + len(d.pathSuggestions), len(d.allPathSuggestions)) + } else { + footerText = " ^N/^P cycle │ Tab accept " + } + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(ColorBorder).Background(menuBg).Render(footerText)) + + // Wrap in a bordered menu box + menuStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorBorder). + Background(menuBg). + Padding(0, 1) + + return menuStyle.Render(b.String()) +} diff --git a/internal/ui/newdialog_test.go b/internal/ui/newdialog_test.go index d1fb4b628..7615072a3 100644 --- a/internal/ui/newdialog_test.go +++ b/internal/ui/newdialog_test.go @@ -1283,3 +1283,70 @@ func TestNewDialog_ToggleWorktree_CustomPrefix(t *testing.T) { t.Errorf("expected branch %q, got %q", "dev/cool-feature", got) } } + +func TestOverlayDropdown_Basic(t *testing.T) { + base := "line0\nline1\nline2\nline3\nline4" + overlay := "AAA\nBBB" + + result := overlayDropdown(base, overlay, 1, 0) + lines := strings.Split(result, "\n") + + if len(lines) != 5 { + t.Fatalf("expected 5 lines, got %d", len(lines)) + } + if lines[0] != "line0" { + t.Errorf("line 0: expected %q, got %q", "line0", lines[0]) + } + // overlay at col 0: "AAA" replaces first 3 chars of "line1", remainder "e1" preserved + if lines[1] != "AAAe1" { + t.Errorf("line 1: expected %q, got %q", "AAAe1", lines[1]) + } +} + +func TestOverlayDropdown_WithOffset(t *testing.T) { + base := "0123456789\n0123456789\n0123456789" + overlay := "XX" + + result := overlayDropdown(base, overlay, 1, 3) + lines := strings.Split(result, "\n") + + // Line 1 should be "012XX56789" + if lines[1] != "012XX56789" { + t.Errorf("expected %q, got %q", "012XX56789", lines[1]) + } + // Other lines unchanged + if lines[0] != "0123456789" { + t.Errorf("line 0 should be unchanged, got %q", lines[0]) + } + if lines[2] != "0123456789" { + t.Errorf("line 2 should be unchanged, got %q", lines[2]) + } +} + +func TestOverlayDropdown_PreservesLineCount(t *testing.T) { + base := "a\nb\nc\nd\ne\nf" + overlay := "X\nY\nZ" + + result := overlayDropdown(base, overlay, 2, 0) + lines := strings.Split(result, "\n") + + if len(lines) != 6 { + t.Fatalf("overlay should not change line count: expected 6, got %d", len(lines)) + } +} + +func TestOverlayDropdown_OutOfBounds(t *testing.T) { + base := "a\nb" + overlay := "X\nY\nZ" + + // Overlay starts at row 1, only 1 line fits (row 1), row 2 is out of bounds + result := overlayDropdown(base, overlay, 1, 0) + lines := strings.Split(result, "\n") + + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + if lines[0] != "a" { + t.Errorf("line 0 should be unchanged, got %q", lines[0]) + } +} From ebbaf10bc969bee3e47a75e7198834b48486f64d Mon Sep 17 00:00:00 2001 From: Or Yaacov Date: Fri, 27 Mar 2026 20:22:37 +0300 Subject: [PATCH 2/2] fix: render path suggestions as floating overlay to prevent dialog jump Replace inline path suggestion rendering with the overlay dropdown from the previous commit. The suggestions now float over the dialog content below them instead of pushing it down, eliminating the layout jump when navigating to/from the path field. Both single-path and multi-repo modes use the shared overlay. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/ui/newdialog.go | 163 ++++++++++----------------------------- 1 file changed, 39 insertions(+), 124 deletions(-) diff --git a/internal/ui/newdialog.go b/internal/ui/newdialog.go index 03b166380..b57cd85cf 100644 --- a/internal/ui/newdialog.go +++ b/internal/ui/newdialog.go @@ -119,15 +119,6 @@ func sliceVisibleFrom(s string, n int) string { return "" } -// dropdownMenuBg returns a slightly elevated background color for floating menus. -// Dark theme: one step brighter than Surface. Light theme: one step darker. -func dropdownMenuBg() lipgloss.Color { - if currentTheme == ThemeLight { - return lipgloss.Color("#dcdde2") - } - return lipgloss.Color("#292e42") -} - // focusTarget identifies a focusable element in the new session dialog. type focusTarget int @@ -1492,62 +1483,8 @@ func (d *NewDialog) View() string { } content.WriteString(dimStyle.Render(" [a: add, d: remove, enter: edit, ↑↓: navigate]")) content.WriteString("\n") - // Show path suggestions dropdown when editing a multi-repo path - if d.multiRepoEditing && len(d.pathSuggestions) > 0 { - suggestionStyle := lipgloss.NewStyle(). - Foreground(ColorComment) - selectedStyle := lipgloss.NewStyle(). - Foreground(ColorCyan). - Bold(true) - - maxShow := 5 - total := len(d.pathSuggestions) - startIdx := 0 - endIdx := total - if total > maxShow { - startIdx = d.pathSuggestionCursor - maxShow/2 - if startIdx < 0 { - startIdx = 0 - } - endIdx = startIdx + maxShow - if endIdx > total { - endIdx = total - startIdx = endIdx - maxShow - } - } - - var headerText string - if len(d.pathSuggestions) < len(d.allPathSuggestions) { - headerText = fmt.Sprintf("─ recent paths (%d/%d matching, ^N/^P: cycle, Tab: accept) ─", - len(d.pathSuggestions), len(d.allPathSuggestions)) - } else { - headerText = "─ recent paths (^N/^P: cycle, Tab: accept) ─" - } - content.WriteString(" ") - content.WriteString(lipgloss.NewStyle().Foreground(ColorComment).Render(headerText)) - content.WriteString("\n") - - if startIdx > 0 { - content.WriteString(suggestionStyle.Render(fmt.Sprintf(" ↑ %d more above", startIdx))) - content.WriteString("\n") - } - - for i := startIdx; i < endIdx; i++ { - style := suggestionStyle - prefix := " " - if i == d.pathSuggestionCursor { - style = selectedStyle - prefix = " ▶ " - } - content.WriteString(style.Render(prefix + d.pathSuggestions[i])) - content.WriteString("\n") - } - - if endIdx < total { - content.WriteString(suggestionStyle.Render(fmt.Sprintf(" ↓ %d more below", total-endIdx))) - content.WriteString("\n") - } - } + // Record line offset for suggestions overlay (rendered after dialog is placed). + d.suggestionsLineOffset = strings.Count(content.String(), "\n") } else { for i, p := range d.multiRepoPaths { display := p @@ -1577,62 +1514,8 @@ func (d *NewDialog) View() string { } content.WriteString("\n") - // Show path suggestions dropdown when path field is focused - if cur == focusPath && len(d.pathSuggestions) > 0 { - suggestionStyle := lipgloss.NewStyle(). - Foreground(ColorComment) - selectedStyle := lipgloss.NewStyle(). - Foreground(ColorCyan). - Bold(true) - - maxShow := 5 - total := len(d.pathSuggestions) - startIdx := 0 - endIdx := total - if total > maxShow { - startIdx = d.pathSuggestionCursor - maxShow/2 - if startIdx < 0 { - startIdx = 0 - } - endIdx = startIdx + maxShow - if endIdx > total { - endIdx = total - startIdx = endIdx - maxShow - } - } - - var headerText string - if len(d.pathSuggestions) < len(d.allPathSuggestions) { - headerText = fmt.Sprintf("─ recent paths (%d/%d matching, ^N/^P: cycle, Tab: accept) ─", - len(d.pathSuggestions), len(d.allPathSuggestions)) - } else { - headerText = "─ recent paths (^N/^P: cycle, Tab: accept) ─" - } - content.WriteString(" ") - content.WriteString(lipgloss.NewStyle().Foreground(ColorComment).Render(headerText)) - content.WriteString("\n") - - if startIdx > 0 { - content.WriteString(suggestionStyle.Render(fmt.Sprintf(" ↑ %d more above", startIdx))) - content.WriteString("\n") - } - - for i := startIdx; i < endIdx; i++ { - style := suggestionStyle - prefix := " " - if i == d.pathSuggestionCursor { - style = selectedStyle - prefix = " ▶ " - } - content.WriteString(style.Render(prefix + d.pathSuggestions[i])) - content.WriteString("\n") - } - - if endIdx < total { - content.WriteString(suggestionStyle.Render(fmt.Sprintf(" ↓ %d more below", total-endIdx))) - content.WriteString("\n") - } - } + // Record line offset for suggestions overlay (rendered after dialog is placed). + d.suggestionsLineOffset = strings.Count(content.String(), "\n") } content.WriteString("\n") @@ -1805,17 +1688,49 @@ func (d *NewDialog) View() string { dialog := dialogStyle.Render(content.String()) // Center the dialog - return lipgloss.Place( + placed := lipgloss.Place( d.width, d.height, lipgloss.Center, lipgloss.Center, dialog, ) + + // Overlay path suggestions dropdown if visible. + // Rendered as a floating bordered menu over the placed dialog so it + // doesn't shift the layout when it appears/disappears. + if suggestionsOverlay := d.renderSuggestionsDropdown(); suggestionsOverlay != "" { + // Find where to place the overlay: + // The dialog is centered, so we need the dialog's top-left position + // within the placed output, plus the line offset to the path input. + dialogHeight := lipgloss.Height(dialog) + dialogWidth := lipgloss.Width(dialog) + topRow := (d.height - dialogHeight) / 2 + leftCol := (d.width - dialogWidth) / 2 + + // suggestionsLineOffset is the content line where the dropdown should appear. + // Add border (1) + top padding (2) to get the actual row within the dialog box. + overlayRow := topRow + 1 + 2 + d.suggestionsLineOffset + // Align with the path input: border (1) + padding (4) + overlayCol := leftCol + 1 + 4 + + placed = overlayDropdown(placed, suggestionsOverlay, overlayRow, overlayCol) + } + + return placed +} + +// renderSuggestionsDropdown renders the path suggestions as a standalone block +// for overlay positioning. Returns empty string if no suggestions to show. +// dropdownMenuBg returns a slightly elevated background color for floating menus. +// Dark theme: one step brighter than Surface. Light theme: one step darker. +func dropdownMenuBg() lipgloss.Color { + if currentTheme == ThemeLight { + return lipgloss.Color("#dcdde2") + } + return lipgloss.Color("#292e42") } -// renderSuggestionsDropdown renders the path suggestions as a bordered floating -// menu for overlay positioning. Returns empty string if no suggestions to show. func (d *NewDialog) renderSuggestionsDropdown() string { cur := d.currentTarget()