diff --git a/installer/gentleman-installer b/installer/gentleman-installer index 1191a9a..00f364a 100755 Binary files a/installer/gentleman-installer and b/installer/gentleman-installer differ diff --git a/installer/go.mod b/installer/go.mod index cef7967..a83b530 100644 --- a/installer/go.mod +++ b/installer/go.mod @@ -5,6 +5,7 @@ go 1.25.1 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 ) require ( @@ -14,7 +15,6 @@ require ( github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect - github.com/charmbracelet/x/exp/teatest v0.0.0-20251215102626-e0db08df7383 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/installer/go.sum b/installer/go.sum index 77b2d0e..05751ef 100644 --- a/installer/go.sum +++ b/installer/go.sum @@ -4,8 +4,6 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -41,14 +39,11 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/installer/internal/tui/installer.go b/installer/internal/tui/installer.go index b0575aa..945286c 100644 --- a/installer/internal/tui/installer.go +++ b/installer/internal/tui/installer.go @@ -1039,6 +1039,13 @@ func stepInstallNvim(m *Model) error { err) } + // Write custom leader key configuration + if err := writeNvimLeaderConfig(m); err != nil { + return wrapStepError("nvim", "Install Neovim", + "Failed to write leader key configuration", + err) + } + // Install Claude Code (optional, don't fail on error) // Skip on Termux - Claude Code doesn't support Android if !m.SystemInfo.IsTermux { @@ -1257,3 +1264,94 @@ fi SendLog(stepID, "Log out and log back in for changes to take effect") return nil } + +// writeNvimLeaderConfig writes the custom leader key and experience level to options.lua +func writeNvimLeaderConfig(m *Model) error { + homeDir := os.Getenv("HOME") + optionsPath := filepath.Join(homeDir, ".config/nvim/lua/config/options.lua") + + leader := m.Choices.LeaderKey + luaLeader := " " // Default space + switch leader { + case "comma": + luaLeader = "," + case "backslash": + luaLeader = "\\\\" + } + + // Read existing options.lua if it exists + content := "" + if data, err := os.ReadFile(optionsPath); err == nil { + content = string(data) + } + + // Prepare the leader config block + var sb strings.Builder + sb.WriteString("-- Leader key configuration (Generated by Gentleman.Dots)\n") + sb.WriteString(fmt.Sprintf("vim.g.mapleader = \"%s\"\n", luaLeader)) + sb.WriteString(fmt.Sprintf("vim.g.maplocalleader = \"%s\"\n\n", luaLeader)) + + // Add experience level as a comment for future reference + sb.WriteString(fmt.Sprintf("-- Experience level: %s\n\n", m.Choices.Experience)) + + // Prepend to existing content or just write if empty + newContent := sb.String() + content + if err := os.WriteFile(optionsPath, []byte(newContent), 0644); err != nil { + return err + } + + // Handle flash.nvim conflict if leader is comma + if leader == "comma" { + return writeFlashFixConfig() + } + + return nil +} + +// writeFlashFixConfig creates a plugin override for flash.nvim when leader is comma +func writeFlashFixConfig() error { + homeDir := os.Getenv("HOME") + // Ensure we are writing to the correct Neovim config path + pluginsDir := filepath.Join(homeDir, ".config", "nvim", "lua", "plugins") + fixPath := filepath.Join(pluginsDir, "flash_fix.lua") + + // Ensure the directory exists (it should after CopyDir, but let's be safe) + if err := os.MkdirAll(pluginsDir, 0755); err != nil { + return fmt.Errorf("failed to create plugins directory: %w", err) + } + + flashConfig := `return { + { + "folke/flash.nvim", + opts = { + modes = { + char = { + jump_labels = true, + -- Ensure "_" is included in the keys that flash listens to + keys = { "f", "F", "t", "T", ";", "_" }, + char_actions = function(motion) + return { + [";"] = "next", -- Use the ";" action for forward + ["_"] = "prev", -- Use the "_" action for backward + } + end, + }, + search = { + -- Use the 'keys' table for search (/) mode overrides + enabled = true, + keys = { + [";"] = "next", + ["_"] = "prev", + }, + }, + }, + }, + }, +} +` + if err := os.WriteFile(fixPath, []byte(flashConfig), 0644); err != nil { + return fmt.Errorf("failed to write flash_fix.lua: %w", err) + } + + return nil +} diff --git a/installer/internal/tui/leader_nav_test.go b/installer/internal/tui/leader_nav_test.go new file mode 100644 index 0000000..ed59366 --- /dev/null +++ b/installer/internal/tui/leader_nav_test.go @@ -0,0 +1,96 @@ +package tui + +import ( + "testing" + + "github.com/Gentleman-Programming/Gentleman.Dots/installer/internal/system" + tea "github.com/charmbracelet/bubbletea" +) + +// mockSystemInfo returns a basic system info for testing +func mockSystemInfo() *system.SystemInfo { + return &system.SystemInfo{ + OS: system.OSMac, + OSName: "macOS", + } +} + +func TestLeaderNavigationFlow(t *testing.T) { + t.Run("Full Nvim Configuration Flow", func(t *testing.T) { + m := NewModel() + m.SystemInfo = mockSystemInfo() + m.Screen = ScreenNvimSelect + m.Cursor = 0 // "Yes" option + + // 1. Press Enter on Nvim Select (Yes) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + + if m.Screen != ScreenExperienceSelect { + t.Errorf("Expected ScreenExperienceSelect, got %v", m.Screen) + } + + // 2. Select "Advanced" (Cursor 2) and Press Enter + m.Cursor = 2 + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + + if m.Choices.Experience != "advanced" { + t.Errorf("Expected experience 'advanced', got %s", m.Choices.Experience) + } + if m.Screen != ScreenLeaderKeySelect { + t.Errorf("Expected ScreenLeaderKeySelect, got %v", m.Screen) + } + + // 3. Select "Comma" (Cursor 1) and Press Enter + m.Cursor = 1 + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + + if m.Choices.LeaderKey != "comma" { + t.Errorf("Expected leader 'comma', got %s", m.Choices.LeaderKey) + } + // Should have proceeded to either Backup or Installing + if m.Screen != ScreenBackupConfirm && m.Screen != ScreenInstalling { + t.Errorf("Expected ScreenBackupConfirm or ScreenInstalling, got %v", m.Screen) + } + }) + + t.Run("Skip Nvim Flow", func(t *testing.T) { + m := NewModel() + m.SystemInfo = mockSystemInfo() + m.Screen = ScreenNvimSelect + m.Cursor = 1 // "No" option + + // Press Enter on Nvim Select (No) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + + // Should skip Experience and Leader screens + if m.Screen == ScreenExperienceSelect || m.Screen == ScreenLeaderKeySelect { + t.Errorf("Should have skipped nvim sub-screens, got %v", m.Screen) + } + }) + + t.Run("Go Back Navigation", func(t *testing.T) { + m := NewModel() + m.SystemInfo = mockSystemInfo() + m.Screen = ScreenLeaderKeySelect + + // Press Esc on Leader Key screen + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = model.(Model) + + if m.Screen != ScreenExperienceSelect { + t.Errorf("Expected ScreenExperienceSelect after back from Leader, got %v", m.Screen) + } + + // Press Esc on Experience screen + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = model.(Model) + + if m.Screen != ScreenNvimSelect { + t.Errorf("Expected ScreenNvimSelect after back from Experience, got %v", m.Screen) + } + }) +} diff --git a/installer/internal/tui/model.go b/installer/internal/tui/model.go index fa13c62..f3a48cd 100644 --- a/installer/internal/tui/model.go +++ b/installer/internal/tui/model.go @@ -49,6 +49,9 @@ const ( ScreenRestoreConfirm // Warning screens ScreenGhosttyWarning // Warning about Ghostty compatibility on Debian/Ubuntu + // Neovim specific screens + ScreenExperienceSelect // Select experience level + ScreenLeaderKeySelect // Select leader key // Vim Trainer screens ScreenTrainerMenu // Module selection ScreenTrainerLesson // Lesson mode @@ -87,7 +90,9 @@ type UserChoices struct { Shell string // "fish", "zsh", "nushell" WindowMgr string // "tmux", "zellij", "none" InstallNvim bool - CreateBackup bool // Whether to backup existing configs + Experience string // "beginner", "intermediate", "advanced" + LeaderKey string // "space", "comma", "backslash" + CreateBackup bool // Whether to backup existing configs } // Model is the main application state @@ -155,7 +160,10 @@ func NewModel() Model { Width: 80, Height: 24, SystemInfo: system.Detect(), - Choices: UserChoices{}, + Choices: UserChoices{ + Experience: "intermediate", + LeaderKey: "space", + }, Steps: []InstallStep{}, CurrentStep: 0, Cursor: 0, @@ -286,6 +294,18 @@ func (m Model) GetCurrentOptions() []string { return []string{"Tmux", "Zellij", "None", "─────────────", "ℹ️ Learn about multiplexers"} case ScreenNvimSelect: return []string{"Yes, install Neovim with config", "No, skip Neovim", "─────────────", "ℹ️ Learn about Neovim", "⌨️ View Keymaps", "📖 LazyVim Guide"} + case ScreenExperienceSelect: + return []string{ + "Beginner (I'm new to Vim)", + "Intermediate (I know the basics)", + "Advanced (Vim is my life)", + } + case ScreenLeaderKeySelect: + return []string{ + "Space (Recommended)", + "Comma (,)", + "Backslash (\\)", + } case ScreenBackupConfirm: return []string{ "✅ Install with Backup (recommended)", @@ -384,6 +404,10 @@ func (m Model) GetScreenTitle() string { return "Step 5: Choose Window Manager" case ScreenNvimSelect: return "Step 6: Neovim Configuration" + case ScreenExperienceSelect: + return "Step 6a: Neovim Experience Level" + case ScreenLeaderKeySelect: + return "Step 6b: Select Leader Key" case ScreenBackupConfirm: return "⚠️ Existing Configs Detected" case ScreenRestoreBackup: @@ -482,6 +506,10 @@ func (m Model) GetScreenDescription() string { return "Terminal multiplexer for managing sessions" case ScreenNvimSelect: return "Includes LSP, TreeSitter, and Gentleman config" + case ScreenExperienceSelect: + return "This will tailor the documentation and learning guides to your level" + case ScreenLeaderKeySelect: + return "The prefix key used for most custom shortcuts (Leader)" case ScreenGhosttyWarning: return "Ghostty installation may fail on Ubuntu/Debian.\nThe installer script only supports certain versions." default: diff --git a/installer/internal/tui/update.go b/installer/internal/tui/update.go index b8bd4ed..28d81a1 100644 --- a/installer/internal/tui/update.go +++ b/installer/internal/tui/update.go @@ -253,7 +253,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case ScreenMainMenu: return m.handleMainMenuKeys(key) - case ScreenOSSelect, ScreenTerminalSelect, ScreenFontSelect, ScreenShellSelect, ScreenWMSelect, ScreenNvimSelect, ScreenGhosttyWarning: + case ScreenOSSelect, ScreenTerminalSelect, ScreenFontSelect, ScreenShellSelect, ScreenWMSelect, ScreenNvimSelect, ScreenExperienceSelect, ScreenLeaderKeySelect, ScreenGhosttyWarning: return m.handleSelectionKeys(key) case ScreenLearnTerminals, ScreenLearnShells, ScreenLearnWM, ScreenLearnNvim: @@ -342,7 +342,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleEscape() (tea.Model, tea.Cmd) { switch m.Screen { // Installation wizard screens - go back through the flow - case ScreenOSSelect, ScreenTerminalSelect, ScreenFontSelect, ScreenShellSelect, ScreenWMSelect, ScreenNvimSelect: + case ScreenOSSelect, ScreenTerminalSelect, ScreenFontSelect, ScreenShellSelect, ScreenWMSelect, ScreenNvimSelect, ScreenExperienceSelect, ScreenLeaderKeySelect: return m.goBackInstallStep() case ScreenGhosttyWarning: // Go back to terminal selection @@ -553,6 +553,14 @@ func (m Model) goBackInstallStep() (tea.Model, tea.Cmd) { m.Screen = ScreenWMSelect m.Cursor = 0 m.Choices.InstallNvim = false + + case ScreenExperienceSelect: + m.Screen = ScreenNvimSelect + m.Cursor = 0 + + case ScreenLeaderKeySelect: + m.Screen = ScreenExperienceSelect + m.Cursor = 0 } return m, nil @@ -678,24 +686,59 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) { case ScreenNvimSelect: m.Choices.InstallNvim = m.Cursor == 0 - // Detect existing configs before proceeding - m.ExistingConfigs = system.DetectExistingConfigs() - if len(m.ExistingConfigs) > 0 { - // Show backup confirmation screen - m.Screen = ScreenBackupConfirm + if m.Choices.InstallNvim { + m.Screen = ScreenExperienceSelect m.Cursor = 0 } else { - // No existing configs, proceed directly - m.SetupInstallSteps() - m.Screen = ScreenInstalling - m.CurrentStep = 0 - return m, func() tea.Msg { return installStartMsg{} } + return m.proceedToBackupOrInstall() } + + case ScreenExperienceSelect: + switch m.Cursor { + case 0: + m.Choices.Experience = "beginner" + case 1: + m.Choices.Experience = "intermediate" + case 2: + m.Choices.Experience = "advanced" + } + m.Screen = ScreenLeaderKeySelect + m.Cursor = 0 + return m, nil + + case ScreenLeaderKeySelect: + switch m.Cursor { + case 0: + m.Choices.LeaderKey = "space" + case 1: + m.Choices.LeaderKey = "comma" + case 2: + m.Choices.LeaderKey = "backslash" + } + return m.proceedToBackupOrInstall() } return m, nil } +// proceedToBackupOrInstall handles the transition after tool selection is complete +func (m Model) proceedToBackupOrInstall() (tea.Model, tea.Cmd) { + // Detect existing configs before proceeding + m.ExistingConfigs = system.DetectExistingConfigs() + if len(m.ExistingConfigs) > 0 { + // Show backup confirmation screen + m.Screen = ScreenBackupConfirm + m.Cursor = 0 + } else { + // No existing configs, proceed directly + m.SetupInstallSteps() + m.Screen = ScreenInstalling + m.CurrentStep = 0 + return m, func() tea.Msg { return installStartMsg{} } + } + return m, nil +} + func (m Model) handleLearnMenuKeys(key string) (tea.Model, tea.Cmd) { options := m.GetCurrentOptions() diff --git a/installer/internal/tui/view.go b/installer/internal/tui/view.go index cfc066a..a732e19 100644 --- a/installer/internal/tui/view.go +++ b/installer/internal/tui/view.go @@ -62,7 +62,7 @@ func (m Model) View() string { s.WriteString(m.renderWelcome()) case ScreenMainMenu: s.WriteString(m.renderMainMenu()) - case ScreenOSSelect, ScreenTerminalSelect, ScreenFontSelect, ScreenShellSelect, ScreenWMSelect, ScreenNvimSelect, ScreenGhosttyWarning: + case ScreenOSSelect, ScreenTerminalSelect, ScreenFontSelect, ScreenShellSelect, ScreenWMSelect, ScreenNvimSelect, ScreenExperienceSelect, ScreenLeaderKeySelect, ScreenGhosttyWarning: s.WriteString(m.renderSelection()) case ScreenLearnTerminals: s.WriteString(m.renderLearnTerminals()) @@ -243,7 +243,7 @@ func (m Model) renderStepProgress() string { currentIdx = 3 case ScreenWMSelect: currentIdx = 4 - case ScreenNvimSelect: + case ScreenNvimSelect, ScreenExperienceSelect, ScreenLeaderKeySelect: currentIdx = 5 }