diff --git a/internal/ui/newdialog.go b/internal/ui/newdialog.go index 31e8cd22f..b57cd85cf 100644 --- a/internal/ui/newdialog.go +++ b/internal/ui/newdialog.go @@ -16,6 +16,109 @@ 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 "" +} + // focusTarget identifies a focusable element in the new session dialog. type focusTarget int @@ -70,8 +173,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. @@ -1379,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 @@ -1464,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") @@ -1692,11 +1688,123 @@ 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") +} + +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]) + } +}