detached 11cad8a

fix(ui): improve form layout, width control, and fix install file paths across phases

commit
11cad8a78c26af0c87abbf53e1de0e318340ede6
author
bnema · · 2025-10-26 17:21 UTC
parent
da00894
signature
unsigned
12 files · +176 -100
M cmd/archup-installer/main.go +3 -2
@@ -54,8 +54,9 @@ func main() {
5454 log.Info("Running in DRY-RUN mode - commands will be logged but not executed")
5555 }
5656
57- // Load or create config
58- cfg := config.NewConfig()
57+ // Load or create config (pass version to determine correct branch for downloads)
58+ cfg := config.NewConfig(version)
59+ log.Info("Config initialized", "version", version, "raw_url", cfg.RawURL)
5960
6061 // Initialize orchestrator
6162 orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath)
@@ -54,8 +54,9 @@ func main() {
54 log.Info("Running in DRY-RUN mode - commands will be logged but not executed")54 log.Info("Running in DRY-RUN mode - commands will be logged but not executed")
55 }55 }
56 56
57- // Load or create config57+ // Load or create config (pass version to determine correct branch for downloads)
58- cfg := config.NewConfig()58+ cfg := config.NewConfig(version)
59+ log.Info("Config initialized", "version", version, "raw_url", cfg.RawURL)
59 60
60 // Initialize orchestrator61 // Initialize orchestrator
61 orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath)62 orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath)
M internal/config/config.go +11 -4
@@ -235,7 +235,14 @@ type Config struct {
235235 }
236236
237237 // NewConfig creates a new Config with sensible defaults
238-func NewConfig() *Config {
238+// version parameter determines which branch to use for downloads (dev builds use dev branch)
239+func NewConfig(version string) *Config {
240+ // Determine which branch to use based on version
241+ branch := "main"
242+ if version == "dev" || version == "" {
243+ branch = "dev"
244+ }
245+
239246 return &Config{
240247 Locale: "en_US.UTF-8",
241248 Timezone: "UTC",
@@ -251,13 +258,13 @@ func NewConfig() *Config {
251258 ConfigPath: DefaultConfigPath,
252259 LogPath: DefaultLogPath,
253260 RepoURL: "https://github.com/bnema/archup",
254- RawURL: "https://raw.githubusercontent.com/bnema/archup/main",
261+ RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", branch),
255262 }
256263 }
257264
258265 // Load reads configuration from a shell-compatible config file
259-func Load(path string) (*Config, error) {
260- cfg := NewConfig()
266+func Load(path string, version string) (*Config, error) {
267+ cfg := NewConfig(version)
261268 cfg.ConfigPath = path
262269
263270 file, err := os.Open(path)
@@ -235,7 +235,14 @@ type Config struct {
235 }235 }
236 236
237 // NewConfig creates a new Config with sensible defaults237 // NewConfig creates a new Config with sensible defaults
238-func NewConfig() *Config {238+// version parameter determines which branch to use for downloads (dev builds use dev branch)
239+func NewConfig(version string) *Config {
240+ // Determine which branch to use based on version
241+ branch := "main"
242+ if version == "dev" || version == "" {
243+ branch = "dev"
244+ }
245+
239 return &Config{246 return &Config{
240 Locale: "en_US.UTF-8",247 Locale: "en_US.UTF-8",
241 Timezone: "UTC",248 Timezone: "UTC",
@@ -251,13 +258,13 @@ func NewConfig() *Config {
251 ConfigPath: DefaultConfigPath,258 ConfigPath: DefaultConfigPath,
252 LogPath: DefaultLogPath,259 LogPath: DefaultLogPath,
253 RepoURL: "https://github.com/bnema/archup",260 RepoURL: "https://github.com/bnema/archup",
254- RawURL: "https://raw.githubusercontent.com/bnema/archup/main",261+ RawURL: fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/%s", branch),
255 }262 }
256 }263 }
257 264
258 // Load reads configuration from a shell-compatible config file265 // Load reads configuration from a shell-compatible config file
259-func Load(path string) (*Config, error) {266+func Load(path string, version string) (*Config, error) {
260- cfg := NewConfig()267+ cfg := NewConfig(version)
261 cfg.ConfigPath = path268 cfg.ConfigPath = path
262 269
263 file, err := os.Open(path)270 file, err := os.Open(path)
M internal/phases/base.go +2 -8
@@ -205,14 +205,8 @@ func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error
205205 // loadBasePackages reads the base package list from file
206206 func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
207207 // Read from install directory (downloaded during bootstrap)
208- installPath := os.Getenv("ARCHUP_INSTALL")
209- switch {
210- case installPath == "":
211- home := os.Getenv("HOME")
212- installPath = home + "/" + config.DefaultInstallPath
213- }
214-
215- packageFile := installPath + "/" + config.BasePackagesFile
208+ // Use DefaultInstallDir directly to match where bootstrap downloads files
209+ packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile
216210
217211 file, err := os.Open(packageFile)
218212 switch {
@@ -205,14 +205,8 @@ func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error
205 // loadBasePackages reads the base package list from file205 // loadBasePackages reads the base package list from file
206 func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {206 func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
207 // Read from install directory (downloaded during bootstrap)207 // Read from install directory (downloaded during bootstrap)
208- installPath := os.Getenv("ARCHUP_INSTALL")208+ // Use DefaultInstallDir directly to match where bootstrap downloads files
209- switch {209+ packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile
210- case installPath == "":
211- home := os.Getenv("HOME")
212- installPath = home + "/" + config.DefaultInstallPath
213- }
214-
215- packageFile := installPath + "/" + config.BasePackagesFile
216 210
217 file, err := os.Open(packageFile)211 file, err := os.Open(packageFile)
218 switch {212 switch {
M internal/phases/boot.go +2 -7
@@ -263,13 +263,8 @@ func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error {
263263
264264 // getInstallPath constructs full path to install file
265265 func (p *BootPhase) getInstallPath(filename string) string {
266- installPath := os.Getenv("ARCHUP_INSTALL")
267- switch {
268- case installPath == "":
269- home := os.Getenv("HOME")
270- installPath = filepath.Join(home, config.DefaultInstallPath)
271- }
272- return filepath.Join(installPath, filename)
266+ // Use DefaultInstallDir directly to match where bootstrap downloads files
267+ return filepath.Join(config.DefaultInstallDir, filename)
273268 }
274269
275270 // extractPartitionNumber extracts partition number from device path
@@ -263,13 +263,8 @@ func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error {
263 263
264 // getInstallPath constructs full path to install file264 // getInstallPath constructs full path to install file
265 func (p *BootPhase) getInstallPath(filename string) string {265 func (p *BootPhase) getInstallPath(filename string) string {
266- installPath := os.Getenv("ARCHUP_INSTALL")266+ // Use DefaultInstallDir directly to match where bootstrap downloads files
267- switch {267+ return filepath.Join(config.DefaultInstallDir, filename)
268- case installPath == "":
269- home := os.Getenv("HOME")
270- installPath = filepath.Join(home, config.DefaultInstallPath)
271- }
272- return filepath.Join(installPath, filename)
273 }268 }
274 269
275 // extractPartitionNumber extracts partition number from device path270 // extractPartitionNumber extracts partition number from device path
M internal/phases/bootstrap.go +0 -4
@@ -91,10 +91,6 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
9191 url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL),
9292 destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"),
9393 },
94- {
95- url: fmt.Sprintf("%s/install/configs/shell/functions", p.config.RawURL),
96- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "functions"),
97- },
9894 {
9995 url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL),
10096 destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"),
@@ -91,10 +91,6 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
91 url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL),91 url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL),
92 destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"),92 destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"),
93 },93 },
94- {
95- url: fmt.Sprintf("%s/install/configs/shell/functions", p.config.RawURL),
96- destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "functions"),
97- },
98 {94 {
99 url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL),95 url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL),
100 destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"),96 destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"),
M internal/phases/postinstall.go +2 -8
@@ -509,14 +509,8 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
509509
510510 // readTemplate reads a template file from install path
511511 func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) {
512- installPath := os.Getenv("ARCHUP_INSTALL")
513- switch {
514- case installPath == "":
515- home := os.Getenv("HOME")
516- installPath = filepath.Join(home, config.DefaultInstallPath)
517- }
518-
519- templatePath := filepath.Join(installPath, filename)
512+ // Use DefaultInstallDir directly to match where bootstrap downloads files
513+ templatePath := filepath.Join(config.DefaultInstallDir, filename)
520514 return os.ReadFile(templatePath)
521515 }
522516
@@ -509,14 +509,8 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
509 509
510 // readTemplate reads a template file from install path510 // readTemplate reads a template file from install path
511 func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) {511 func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) {
512- installPath := os.Getenv("ARCHUP_INSTALL")512+ // Use DefaultInstallDir directly to match where bootstrap downloads files
513- switch {513+ templatePath := filepath.Join(config.DefaultInstallDir, filename)
514- case installPath == "":
515- home := os.Getenv("HOME")
516- installPath = filepath.Join(home, config.DefaultInstallPath)
517- }
518-
519- templatePath := filepath.Join(installPath, filename)
520 return os.ReadFile(templatePath)514 return os.ReadFile(templatePath)
521 }515 }
522 516
M internal/phases/repos.go +2 -7
@@ -359,13 +359,8 @@ func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error
359359
360360 // getInstallPath constructs full path to install file
361361 func (p *ReposPhase) getInstallPath(filename string) string {
362- installPath := os.Getenv("ARCHUP_INSTALL")
363- switch {
364- case installPath == "":
365- home := os.Getenv("HOME")
366- installPath = filepath.Join(home, config.DefaultInstallPath)
367- }
368- return filepath.Join(installPath, filename)
362+ // Use DefaultInstallDir directly to match where bootstrap downloads files
363+ return filepath.Join(config.DefaultInstallDir, filename)
369364 }
370365
371366 // PostCheck validates repository configuration
@@ -359,13 +359,8 @@ func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error
359 359
360 // getInstallPath constructs full path to install file360 // getInstallPath constructs full path to install file
361 func (p *ReposPhase) getInstallPath(filename string) string {361 func (p *ReposPhase) getInstallPath(filename string) string {
362- installPath := os.Getenv("ARCHUP_INSTALL")362+ // Use DefaultInstallDir directly to match where bootstrap downloads files
363- switch {363+ return filepath.Join(config.DefaultInstallDir, filename)
364- case installPath == "":
365- home := os.Getenv("HOME")
366- installPath = filepath.Join(home, config.DefaultInstallPath)
367- }
368- return filepath.Join(installPath, filename)
369 }364 }
370 365
371 // PostCheck validates repository configuration366 // PostCheck validates repository configuration
M internal/ui/components/forms.go +17 -6
@@ -2,18 +2,21 @@ package components
22
33 import (
44 "github.com/charmbracelet/huh"
5+ "github.com/charmbracelet/lipgloss"
56 "github.com/bnema/archup/internal/ui/styles"
67 )
78
89 // FormBuilder provides reusable form components
910 type FormBuilder struct {
1011 accessible bool
12+ width int
1113 }
1214
13-// NewFormBuilder creates a new form builder
14-func NewFormBuilder(accessible bool) *FormBuilder {
15+// NewFormBuilder creates a new form builder with specified width
16+func NewFormBuilder(accessible bool, width int) *FormBuilder {
1517 return &FormBuilder{
1618 accessible: accessible,
19+ width: width,
1720 }
1821 }
1922
@@ -63,23 +66,31 @@ func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]stri
6366 return ms
6467 }
6568
66-// Confirm creates a yes/no confirmation with inline button alignment
69+// Confirm creates a yes/no confirmation with left-aligned buttons
6770 func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm {
6871 return huh.NewConfirm().
6972 Title(title).
7073 Affirmative(affirmative).
7174 Negative(negative).
7275 Value(value).
73- Inline(true)
76+ WithButtonAlignment(lipgloss.Left)
7477 }
7578
7679 // CreateForm creates a form with accessibility settings and custom theme
7780 func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form {
78- const maxFormWidth = 80 // Maximum width for better readability
81+ // Use 70% of terminal width for better readability (max 80)
82+ formWidth := fb.width * 70 / 100
83+ if formWidth > 80 {
84+ formWidth = 80
85+ }
86+ if formWidth < 40 {
87+ formWidth = 40 // Minimum width
88+ }
7989
8090 form := huh.NewForm(groups...).
8191 WithTheme(styles.HuhTheme()).
82- WithWidth(maxFormWidth)
92+ WithKeyMap(CreateTTYKeyMap()).
93+ WithWidth(formWidth)
8394
8495 if fb.accessible {
8596 form.WithAccessible(true)
@@ -2,18 +2,21 @@ package components
2 2
3 import (3 import (
4 "github.com/charmbracelet/huh"4 "github.com/charmbracelet/huh"
5+ "github.com/charmbracelet/lipgloss"
5 "github.com/bnema/archup/internal/ui/styles"6 "github.com/bnema/archup/internal/ui/styles"
6 )7 )
7 8
8 // FormBuilder provides reusable form components9 // FormBuilder provides reusable form components
9 type FormBuilder struct {10 type FormBuilder struct {
10 accessible bool11 accessible bool
12+ width int
11 }13 }
12 14
13-// NewFormBuilder creates a new form builder15+// NewFormBuilder creates a new form builder with specified width
14-func NewFormBuilder(accessible bool) *FormBuilder {16+func NewFormBuilder(accessible bool, width int) *FormBuilder {
15 return &FormBuilder{17 return &FormBuilder{
16 accessible: accessible,18 accessible: accessible,
19+ width: width,
17 }20 }
18 }21 }
19 22
@@ -63,23 +66,31 @@ func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]stri
63 return ms66 return ms
64 }67 }
65 68
66-// Confirm creates a yes/no confirmation with inline button alignment69+// Confirm creates a yes/no confirmation with left-aligned buttons
67 func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm {70 func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm {
68 return huh.NewConfirm().71 return huh.NewConfirm().
69 Title(title).72 Title(title).
70 Affirmative(affirmative).73 Affirmative(affirmative).
71 Negative(negative).74 Negative(negative).
72 Value(value).75 Value(value).
73- Inline(true)76+ WithButtonAlignment(lipgloss.Left)
74 }77 }
75 78
76 // CreateForm creates a form with accessibility settings and custom theme79 // CreateForm creates a form with accessibility settings and custom theme
77 func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form {80 func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form {
78- const maxFormWidth = 80 // Maximum width for better readability81+ // Use 70% of terminal width for better readability (max 80)
82+ formWidth := fb.width * 70 / 100
83+ if formWidth > 80 {
84+ formWidth = 80
85+ }
86+ if formWidth < 40 {
87+ formWidth = 40 // Minimum width
88+ }
79 89
80 form := huh.NewForm(groups...).90 form := huh.NewForm(groups...).
81 WithTheme(styles.HuhTheme()).91 WithTheme(styles.HuhTheme()).
82- WithWidth(maxFormWidth)92+ WithKeyMap(CreateTTYKeyMap()).
93+ WithWidth(formWidth)
83 94
84 if fb.accessible {95 if fb.accessible {
85 form.WithAccessible(true)96 form.WithAccessible(true)
A internal/ui/components/keybinds.go +46 -0
@@ -0,0 +1,46 @@
1+package components
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ "github.com/charmbracelet/huh"
6+)
7+
8+// CreateTTYKeyMap creates a custom keymap with TTY-friendly alternatives to Shift+Tab
9+// This is needed for QEMU console and other limited terminal environments where
10+// Shift+Tab is not properly transmitted.
11+func CreateTTYKeyMap() *huh.KeyMap {
12+ km := huh.NewDefaultKeyMap()
13+
14+ // Add Ctrl+P as alternative to Shift+Tab for "previous" navigation
15+ // These work better in QEMU console and limited TTY environments
16+ km.Input.Prev = key.NewBinding(
17+ key.WithKeys("shift+tab", "ctrl+p"),
18+ key.WithHelp("ctrl+p/shift+tab", "back"),
19+ )
20+ km.Text.Prev = key.NewBinding(
21+ key.WithKeys("shift+tab", "ctrl+p"),
22+ key.WithHelp("ctrl+p/shift+tab", "back"),
23+ )
24+ km.Select.Prev = key.NewBinding(
25+ key.WithKeys("shift+tab", "ctrl+p"),
26+ key.WithHelp("ctrl+p/shift+tab", "back"),
27+ )
28+ km.MultiSelect.Prev = key.NewBinding(
29+ key.WithKeys("shift+tab", "ctrl+p"),
30+ key.WithHelp("ctrl+p/shift+tab", "back"),
31+ )
32+ km.Note.Prev = key.NewBinding(
33+ key.WithKeys("shift+tab", "ctrl+p"),
34+ key.WithHelp("ctrl+p/shift+tab", "back"),
35+ )
36+ km.Confirm.Prev = key.NewBinding(
37+ key.WithKeys("shift+tab", "ctrl+p"),
38+ key.WithHelp("ctrl+p/shift+tab", "back"),
39+ )
40+ km.FilePicker.Prev = key.NewBinding(
41+ key.WithKeys("shift+tab", "ctrl+p"),
42+ key.WithHelp("ctrl+p/shift+tab", "back"),
43+ )
44+
45+ return km
46+}
@@ -0,0 +1,46 @@
1+package components
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ "github.com/charmbracelet/huh"
6+)
7+
8+// CreateTTYKeyMap creates a custom keymap with TTY-friendly alternatives to Shift+Tab
9+// This is needed for QEMU console and other limited terminal environments where
10+// Shift+Tab is not properly transmitted.
11+func CreateTTYKeyMap() *huh.KeyMap {
12+ km := huh.NewDefaultKeyMap()
13+
14+ // Add Ctrl+P as alternative to Shift+Tab for "previous" navigation
15+ // These work better in QEMU console and limited TTY environments
16+ km.Input.Prev = key.NewBinding(
17+ key.WithKeys("shift+tab", "ctrl+p"),
18+ key.WithHelp("ctrl+p/shift+tab", "back"),
19+ )
20+ km.Text.Prev = key.NewBinding(
21+ key.WithKeys("shift+tab", "ctrl+p"),
22+ key.WithHelp("ctrl+p/shift+tab", "back"),
23+ )
24+ km.Select.Prev = key.NewBinding(
25+ key.WithKeys("shift+tab", "ctrl+p"),
26+ key.WithHelp("ctrl+p/shift+tab", "back"),
27+ )
28+ km.MultiSelect.Prev = key.NewBinding(
29+ key.WithKeys("shift+tab", "ctrl+p"),
30+ key.WithHelp("ctrl+p/shift+tab", "back"),
31+ )
32+ km.Note.Prev = key.NewBinding(
33+ key.WithKeys("shift+tab", "ctrl+p"),
34+ key.WithHelp("ctrl+p/shift+tab", "back"),
35+ )
36+ km.Confirm.Prev = key.NewBinding(
37+ key.WithKeys("shift+tab", "ctrl+p"),
38+ key.WithHelp("ctrl+p/shift+tab", "back"),
39+ )
40+ km.FilePicker.Prev = key.NewBinding(
41+ key.WithKeys("shift+tab", "ctrl+p"),
42+ key.WithHelp("ctrl+p/shift+tab", "back"),
43+ )
44+
45+ return km
46+}
M internal/ui/installer_forms.go +11 -11
@@ -11,8 +11,7 @@ import (
1111 )
1212
1313 // CreatePreflightForm creates the initial configuration form with all user identity fields
14-func CreatePreflightForm(cfg *config.Config) *huh.Form {
15- fb := components.NewFormBuilder(false)
14+func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
1615
1716 // Auto-detect timezone from API, fallback to default if detection fails
1817 detectedTimezone := system.DetectTimezone()
@@ -61,19 +60,21 @@ func CreatePreflightForm(cfg *config.Config) *huh.Form {
6160 }
6261
6362 // CreateDiskSelectionForm creates disk selection form with detailed disk information
64-func CreateDiskSelectionForm(cfg *config.Config) *huh.Form {
65- fb := components.NewFormBuilder(false)
63+func CreateDiskSelectionForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
6664
6765 // Detect available disks
6866 disks, err := system.ListDisks()
6967
70- var diskOptions []string
68+ var diskOptions []huh.Option[string]
7169 switch {
7270 case err != nil:
73- // Fallback to empty list if detection fails
74- diskOptions = []string{"No disks detected"}
71+ // Fallback to empty option if detection fails
72+ diskOptions = []huh.Option[string]{
73+ huh.NewOption("No disks detected", ""),
74+ }
7575 default:
7676 // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]"
77+ // Use disk.Path as value and formatted string as display label
7778 for _, disk := range disks {
7879 label := disk.Path + " (" + disk.Size + ")"
7980
@@ -95,21 +96,20 @@ func CreateDiskSelectionForm(cfg *config.Config) *huh.Form {
9596 label += " " + disk.Vendor
9697 }
9798
98- diskOptions = append(diskOptions, label)
99+ diskOptions = append(diskOptions, huh.NewOption(label, disk.Path))
99100 }
100101 }
101102
102103 return fb.CreateForm(
103104 huh.NewGroup(
104- fb.Select(SectionDiskSelection, diskOptions, &cfg.TargetDisk).
105+ fb.SelectWithOptions(SectionDiskSelection, diskOptions, &cfg.TargetDisk).
105106 Description(WarnDiskErase),
106107 ),
107108 )
108109 }
109110
110111 // CreateOptionsForm creates installation options form with CPU detection and descriptions
111-func CreateOptionsForm(cfg *config.Config) *huh.Form {
112- fb := components.NewFormBuilder(false)
112+func CreateOptionsForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
113113
114114 // Auto-detect CPU information
115115 cpuInfo, err := system.DetectCPUInfo()
@@ -11,8 +11,7 @@ import (
11 )11 )
12 12
13 // CreatePreflightForm creates the initial configuration form with all user identity fields13 // CreatePreflightForm creates the initial configuration form with all user identity fields
14-func CreatePreflightForm(cfg *config.Config) *huh.Form {14+func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
15- fb := components.NewFormBuilder(false)
16 15
17 // Auto-detect timezone from API, fallback to default if detection fails16 // Auto-detect timezone from API, fallback to default if detection fails
18 detectedTimezone := system.DetectTimezone()17 detectedTimezone := system.DetectTimezone()
@@ -61,19 +60,21 @@ func CreatePreflightForm(cfg *config.Config) *huh.Form {
61 }60 }
62 61
63 // CreateDiskSelectionForm creates disk selection form with detailed disk information62 // CreateDiskSelectionForm creates disk selection form with detailed disk information
64-func CreateDiskSelectionForm(cfg *config.Config) *huh.Form {63+func CreateDiskSelectionForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
65- fb := components.NewFormBuilder(false)
66 64
67 // Detect available disks65 // Detect available disks
68 disks, err := system.ListDisks()66 disks, err := system.ListDisks()
69 67
70- var diskOptions []string68+ var diskOptions []huh.Option[string]
71 switch {69 switch {
72 case err != nil:70 case err != nil:
73- // Fallback to empty list if detection fails71+ // Fallback to empty option if detection fails
74- diskOptions = []string{"No disks detected"}72+ diskOptions = []huh.Option[string]{
73+ huh.NewOption("No disks detected", ""),
74+ }
75 default:75 default:
76 // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]"76 // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]"
77+ // Use disk.Path as value and formatted string as display label
77 for _, disk := range disks {78 for _, disk := range disks {
78 label := disk.Path + " (" + disk.Size + ")"79 label := disk.Path + " (" + disk.Size + ")"
79 80
@@ -95,21 +96,20 @@ func CreateDiskSelectionForm(cfg *config.Config) *huh.Form {
95 label += " " + disk.Vendor96 label += " " + disk.Vendor
96 }97 }
97 98
98- diskOptions = append(diskOptions, label)99+ diskOptions = append(diskOptions, huh.NewOption(label, disk.Path))
99 }100 }
100 }101 }
101 102
102 return fb.CreateForm(103 return fb.CreateForm(
103 huh.NewGroup(104 huh.NewGroup(
104- fb.Select(SectionDiskSelection, diskOptions, &cfg.TargetDisk).105+ fb.SelectWithOptions(SectionDiskSelection, diskOptions, &cfg.TargetDisk).
105 Description(WarnDiskErase),106 Description(WarnDiskErase),
106 ),107 ),
107 )108 )
108 }109 }
109 110
110 // CreateOptionsForm creates installation options form with CPU detection and descriptions111 // CreateOptionsForm creates installation options form with CPU detection and descriptions
111-func CreateOptionsForm(cfg *config.Config) *huh.Form {112+func CreateOptionsForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form {
112- fb := components.NewFormBuilder(false)
113 113
114 // Auto-detect CPU information114 // Auto-detect CPU information
115 cpuInfo, err := system.DetectCPUInfo()115 cpuInfo, err := system.DetectCPUInfo()
M internal/ui/model.go +33 -14
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/archup/internal/phases"
99 "github.com/bnema/archup/internal/ui/components"
1010 "github.com/bnema/archup/internal/ui/styles"
11+ "github.com/charmbracelet/bubbles/spinner"
1112 tea "github.com/charmbracelet/bubbletea"
1213 "github.com/charmbracelet/huh"
1314 "github.com/charmbracelet/lipgloss"
@@ -33,8 +34,10 @@ type Model struct {
3334 orchestrator *phases.Orchestrator
3435 config *config.Config
3536 currentForm *huh.Form
37+ formBuilder *components.FormBuilder
3638 output *components.OutputViewer
3739 logger *logger.Logger
40+ spinner spinner.Model
3841 version string
3942 width int
4043 height int
@@ -44,11 +47,18 @@ type Model struct {
4447 // NewModel creates a new installer model
4548 func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger.Logger, version string) *Model {
4649 log.Info("UI Model created", "version", version)
50+
51+ s := spinner.New()
52+ s.Spinner = spinner.Line
53+ s.Style = styles.SpinnerStyle
54+
4755 return &Model{
4856 state: StateShowLogo,
4957 orchestrator: orchestrator,
5058 config: cfg,
59+ formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg
5160 logger: log,
61+ spinner: s,
5262 version: version,
5363 width: 80,
5464 height: 24,
@@ -58,7 +68,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger
5868
5969 // Init initializes the model
6070 func (m *Model) Init() tea.Cmd {
61- return nil
71+ return m.spinner.Tick
6272 }
6373
6474 // Update handles messages
@@ -68,6 +78,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6878 m.width = msg.Width
6979 m.height = msg.Height
7080 m.output.SetSize(msg.Width, msg.Height-10)
81+ // Update formBuilder width for responsive forms
82+ m.formBuilder = components.NewFormBuilder(false, msg.Width)
7183
7284 case tea.KeyMsg:
7385 m.logger.Info("Key pressed", "key", msg.String(), "state", m.state)
@@ -82,7 +94,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8294 case StateShowLogo:
8395 m.logger.Info("Moving to preflight form")
8496 m.state = StatePreflightForm
85- m.currentForm = CreatePreflightForm(m.config)
97+ m.currentForm = CreatePreflightForm(m.config, m.formBuilder)
8698 return m, m.currentForm.Init()
8799
88100 case StateConfirmation:
@@ -96,17 +108,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96108 switch m.state {
97109 case StateDiskForm:
98110 m.state = StatePreflightForm
99- m.currentForm = CreatePreflightForm(m.config)
111+ m.currentForm = CreatePreflightForm(m.config, m.formBuilder)
100112 return m, m.currentForm.Init()
101113
102114 case StateOptionsForm:
103115 m.state = StateDiskForm
104- m.currentForm = CreateDiskSelectionForm(m.config)
116+ m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder)
105117 return m, m.currentForm.Init()
106118
107119 case StateConfirmation:
108120 m.state = StateOptionsForm
109- m.currentForm = CreateOptionsForm(m.config)
121+ m.currentForm = CreateOptionsForm(m.config, m.formBuilder)
110122 return m, m.currentForm.Init()
111123 }
112124 }
@@ -170,8 +182,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
170182 return m, cmd
171183 }
172184 case StateExecuting:
173- // Update output viewer
174- return m, m.output.Update(msg)
185+ // Update spinner and output viewer
186+ var cmd tea.Cmd
187+ m.spinner, cmd = m.spinner.Update(msg)
188+ return m, tea.Batch(cmd, m.output.Update(msg))
175189 }
176190
177191 return m, nil
@@ -198,7 +212,8 @@ func (m *Model) View() string {
198212 return m.renderConfirmation()
199213
200214 case StateExecuting:
201- return styles.TitleStyle.Render("Installing ArchUp...") + "\n\n" + m.output.View()
215+ title := m.spinner.View() + " Installation in progress..."
216+ return styles.TitleStyle.Render(title) + "\n\n" + m.output.View()
202217
203218 case StateError:
204219 return styles.RenderError(fmt.Sprintf("Error: %v", m.err))
@@ -229,13 +244,13 @@ func (m *Model) handleFormComplete() tea.Cmd {
229244 }
230245
231246 m.state = StateDiskForm
232- m.currentForm = CreateDiskSelectionForm(m.config)
247+ m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder)
233248 return m.currentForm.Init()
234249
235250 case StateDiskForm:
236251 m.logger.Info("Disk selection completed", "disk", m.config.TargetDisk)
237252 m.state = StateOptionsForm
238- m.currentForm = CreateOptionsForm(m.config)
253+ m.currentForm = CreateOptionsForm(m.config, m.formBuilder)
239254 return m.currentForm.Init()
240255
241256 case StateOptionsForm:
@@ -270,10 +285,14 @@ func (m *Model) renderConfirmation() string {
270285
271286 // executeNextPhase executes the next pending phase
272287 func (m *Model) executeNextPhase() tea.Cmd {
273- return func() tea.Msg {
274- err := m.orchestrator.ExecuteNext()
275- return phaseCompleteMsg{err: err}
276- }
288+ // Execute the phase in the background AND start listening for progress updates
289+ return tea.Batch(
290+ func() tea.Msg {
291+ err := m.orchestrator.ExecuteNext()
292+ return phaseCompleteMsg{err: err}
293+ },
294+ waitForProgress(m.orchestrator.ProgressChannel()),
295+ )
277296 }
278297
279298 // waitForProgress waits for progress updates
@@ -8,6 +8,7 @@ import (
8 "github.com/bnema/archup/internal/phases"8 "github.com/bnema/archup/internal/phases"
9 "github.com/bnema/archup/internal/ui/components"9 "github.com/bnema/archup/internal/ui/components"
10 "github.com/bnema/archup/internal/ui/styles"10 "github.com/bnema/archup/internal/ui/styles"
11+ "github.com/charmbracelet/bubbles/spinner"
11 tea "github.com/charmbracelet/bubbletea"12 tea "github.com/charmbracelet/bubbletea"
12 "github.com/charmbracelet/huh"13 "github.com/charmbracelet/huh"
13 "github.com/charmbracelet/lipgloss"14 "github.com/charmbracelet/lipgloss"
@@ -33,8 +34,10 @@ type Model struct {
33 orchestrator *phases.Orchestrator34 orchestrator *phases.Orchestrator
34 config *config.Config35 config *config.Config
35 currentForm *huh.Form36 currentForm *huh.Form
37+ formBuilder *components.FormBuilder
36 output *components.OutputViewer38 output *components.OutputViewer
37 logger *logger.Logger39 logger *logger.Logger
40+ spinner spinner.Model
38 version string41 version string
39 width int42 width int
40 height int43 height int
@@ -44,11 +47,18 @@ type Model struct {
44 // NewModel creates a new installer model47 // NewModel creates a new installer model
45 func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger.Logger, version string) *Model {48 func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger.Logger, version string) *Model {
46 log.Info("UI Model created", "version", version)49 log.Info("UI Model created", "version", version)
50+
51+ s := spinner.New()
52+ s.Spinner = spinner.Line
53+ s.Style = styles.SpinnerStyle
54+
47 return &Model{55 return &Model{
48 state: StateShowLogo,56 state: StateShowLogo,
49 orchestrator: orchestrator,57 orchestrator: orchestrator,
50 config: cfg,58 config: cfg,
59+ formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg
51 logger: log,60 logger: log,
61+ spinner: s,
52 version: version,62 version: version,
53 width: 80,63 width: 80,
54 height: 24,64 height: 24,
@@ -58,7 +68,7 @@ func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger
58 68
59 // Init initializes the model69 // Init initializes the model
60 func (m *Model) Init() tea.Cmd {70 func (m *Model) Init() tea.Cmd {
61- return nil71+ return m.spinner.Tick
62 }72 }
63 73
64 // Update handles messages74 // Update handles messages
@@ -68,6 +78,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68 m.width = msg.Width78 m.width = msg.Width
69 m.height = msg.Height79 m.height = msg.Height
70 m.output.SetSize(msg.Width, msg.Height-10)80 m.output.SetSize(msg.Width, msg.Height-10)
81+ // Update formBuilder width for responsive forms
82+ m.formBuilder = components.NewFormBuilder(false, msg.Width)
71 83
72 case tea.KeyMsg:84 case tea.KeyMsg:
73 m.logger.Info("Key pressed", "key", msg.String(), "state", m.state)85 m.logger.Info("Key pressed", "key", msg.String(), "state", m.state)
@@ -82,7 +94,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82 case StateShowLogo:94 case StateShowLogo:
83 m.logger.Info("Moving to preflight form")95 m.logger.Info("Moving to preflight form")
84 m.state = StatePreflightForm96 m.state = StatePreflightForm
85- m.currentForm = CreatePreflightForm(m.config)97+ m.currentForm = CreatePreflightForm(m.config, m.formBuilder)
86 return m, m.currentForm.Init()98 return m, m.currentForm.Init()
87 99
88 case StateConfirmation:100 case StateConfirmation:
@@ -96,17 +108,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96 switch m.state {108 switch m.state {
97 case StateDiskForm:109 case StateDiskForm:
98 m.state = StatePreflightForm110 m.state = StatePreflightForm
99- m.currentForm = CreatePreflightForm(m.config)111+ m.currentForm = CreatePreflightForm(m.config, m.formBuilder)
100 return m, m.currentForm.Init()112 return m, m.currentForm.Init()
101 113
102 case StateOptionsForm:114 case StateOptionsForm:
103 m.state = StateDiskForm115 m.state = StateDiskForm
104- m.currentForm = CreateDiskSelectionForm(m.config)116+ m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder)
105 return m, m.currentForm.Init()117 return m, m.currentForm.Init()
106 118
107 case StateConfirmation:119 case StateConfirmation:
108 m.state = StateOptionsForm120 m.state = StateOptionsForm
109- m.currentForm = CreateOptionsForm(m.config)121+ m.currentForm = CreateOptionsForm(m.config, m.formBuilder)
110 return m, m.currentForm.Init()122 return m, m.currentForm.Init()
111 }123 }
112 }124 }
@@ -170,8 +182,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
170 return m, cmd182 return m, cmd
171 }183 }
172 case StateExecuting:184 case StateExecuting:
173- // Update output viewer185+ // Update spinner and output viewer
174- return m, m.output.Update(msg)186+ var cmd tea.Cmd
187+ m.spinner, cmd = m.spinner.Update(msg)
188+ return m, tea.Batch(cmd, m.output.Update(msg))
175 }189 }
176 190
177 return m, nil191 return m, nil
@@ -198,7 +212,8 @@ func (m *Model) View() string {
198 return m.renderConfirmation()212 return m.renderConfirmation()
199 213
200 case StateExecuting:214 case StateExecuting:
201- return styles.TitleStyle.Render("Installing ArchUp...") + "\n\n" + m.output.View()215+ title := m.spinner.View() + " Installation in progress..."
216+ return styles.TitleStyle.Render(title) + "\n\n" + m.output.View()
202 217
203 case StateError:218 case StateError:
204 return styles.RenderError(fmt.Sprintf("Error: %v", m.err))219 return styles.RenderError(fmt.Sprintf("Error: %v", m.err))
@@ -229,13 +244,13 @@ func (m *Model) handleFormComplete() tea.Cmd {
229 }244 }
230 245
231 m.state = StateDiskForm246 m.state = StateDiskForm
232- m.currentForm = CreateDiskSelectionForm(m.config)247+ m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder)
233 return m.currentForm.Init()248 return m.currentForm.Init()
234 249
235 case StateDiskForm:250 case StateDiskForm:
236 m.logger.Info("Disk selection completed", "disk", m.config.TargetDisk)251 m.logger.Info("Disk selection completed", "disk", m.config.TargetDisk)
237 m.state = StateOptionsForm252 m.state = StateOptionsForm
238- m.currentForm = CreateOptionsForm(m.config)253+ m.currentForm = CreateOptionsForm(m.config, m.formBuilder)
239 return m.currentForm.Init()254 return m.currentForm.Init()
240 255
241 case StateOptionsForm:256 case StateOptionsForm:
@@ -270,10 +285,14 @@ func (m *Model) renderConfirmation() string {
270 285
271 // executeNextPhase executes the next pending phase286 // executeNextPhase executes the next pending phase
272 func (m *Model) executeNextPhase() tea.Cmd {287 func (m *Model) executeNextPhase() tea.Cmd {
273- return func() tea.Msg {288+ // Execute the phase in the background AND start listening for progress updates
274- err := m.orchestrator.ExecuteNext()289+ return tea.Batch(
275- return phaseCompleteMsg{err: err}290+ func() tea.Msg {
276- }291+ err := m.orchestrator.ExecuteNext()
292+ return phaseCompleteMsg{err: err}
293+ },
294+ waitForProgress(m.orchestrator.ProgressChannel()),
295+ )
277 }296 }
278 297
279 // waitForProgress waits for progress updates298 // waitForProgress waits for progress updates
M internal/ui/styles/theme.go +47 -29
@@ -260,44 +260,62 @@ func RenderHelp(bindings map[string]string) string {
260260 }
261261
262262 // HuhTheme returns a custom huh.Theme matching the bleu color scheme
263+// Uses ThemeBase16 as foundation for maximum TTY compatibility (QEMU console, etc)
263264 func HuhTheme() *huh.Theme {
264- theme := huh.ThemeBase()
265+ // Start with Base16 theme for limited terminal support
266+ theme := huh.ThemeBase16()
267+
268+ // Customize with bleu theme colors using ANSI 16 codes
269+ // ANSI Color mapping for bleu theme:
270+ // 0=black, 4=blue, 6=cyan, 7=white, 8=grey, 10=bright green, 12=bright blue, 14=bright cyan, 15=bright white
265271
266272 // Form and Group styles
267- theme.Form.Base = lipgloss.NewStyle()
268- theme.Group.Base = lipgloss.NewStyle()
269- theme.Group.Title = TitleStyle
270- theme.Group.Description = SubtitleStyle
273+ theme.Group.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
274+ theme.Group.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue
271275
272276 // Focused field styles
273- theme.Focused.Base = lipgloss.NewStyle()
274- theme.Focused.Title = lipgloss.NewStyle().Foreground(ColorBrightCyan).Bold(true)
275- theme.Focused.Description = lipgloss.NewStyle().Foreground(ColorPureBlue)
276- theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(ColorBrightCyan).SetString("> ")
277- theme.Focused.Option = lipgloss.NewStyle().Foreground(ColorPrimaryText)
278- theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(ColorBrightCyan).SetString("> ")
279- theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(ColorBrightCyan)
280- theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(ColorSuccessGreen).SetString("✓ ")
281- theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(ColorPrimaryText)
282- theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(ColorDimmedText).SetString("○ ")
283- theme.Focused.FocusedButton = lipgloss.NewStyle().Foreground(ColorPureWhite).Background(ColorOceanBlue).Padding(0, 2).Bold(true)
284- theme.Focused.BlurredButton = lipgloss.NewStyle().Foreground(ColorDimmedText).Padding(0, 2)
285-
286- // Blurred field styles
287- theme.Blurred.Base = lipgloss.NewStyle()
288- theme.Blurred.Title = lipgloss.NewStyle().Foreground(ColorDimmedText)
289- theme.Blurred.Description = lipgloss.NewStyle().Foreground(ColorDimmedText)
290- theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(ColorDimmedText).SetString("> ")
291- theme.Blurred.Option = lipgloss.NewStyle().Foreground(ColorDimmedText)
277+ theme.Focused.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
278+ theme.Focused.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue
279+ theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ")
280+ theme.Focused.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White
281+ theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ")
282+ theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan
283+ theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).SetString("✓ ") // Bright green
284+ theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White
285+ theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("○ ") // Grey
286+
287+ // Button styles with explicit background colors for TTY compatibility
288+ // Active/focused button (currently selected - e.g., "Yes" when on Yes)
289+ theme.Focused.FocusedButton = lipgloss.NewStyle().
290+ Foreground(lipgloss.Color("15")). // Bright white text
291+ Background(lipgloss.Color("4")). // Blue background
292+ Padding(0, 2).
293+ Bold(true)
294+
295+ // Inactive/blurred button (not selected - e.g., "No" when on Yes)
296+ theme.Focused.BlurredButton = lipgloss.NewStyle().
297+ Foreground(lipgloss.Color("7")). // White text
298+ Background(lipgloss.Color("0")). // Black background
299+ Padding(0, 2)
300+
301+ // Blurred field styles (when entire field loses focus)
302+ theme.Blurred.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
303+ theme.Blurred.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
304+ theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("> ")
305+ theme.Blurred.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
306+
307+ // Override buttons in Blurred state too (inherited from Focused in Base16)
308+ theme.Blurred.FocusedButton = theme.Focused.FocusedButton
309+ theme.Blurred.BlurredButton = theme.Focused.BlurredButton
292310
293311 // Text input styles
294- theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(ColorBrightCyan)
295- theme.Focused.TextInput.Placeholder = PlaceholderStyle
296- theme.Focused.TextInput.Prompt = PromptStyle
312+ theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan
313+ theme.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
314+ theme.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
297315
298316 // Error styles
299- theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(ColorSoftRed).SetString("✗ ")
300- theme.Focused.ErrorMessage = ErrorStyle
317+ theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).SetString("✗ ") // Bright red
318+ theme.Focused.ErrorMessage = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Bright red
301319
302320 return theme
303321 }
@@ -260,44 +260,62 @@ func RenderHelp(bindings map[string]string) string {
260 }260 }
261 261
262 // HuhTheme returns a custom huh.Theme matching the bleu color scheme262 // HuhTheme returns a custom huh.Theme matching the bleu color scheme
263+// Uses ThemeBase16 as foundation for maximum TTY compatibility (QEMU console, etc)
263 func HuhTheme() *huh.Theme {264 func HuhTheme() *huh.Theme {
264- theme := huh.ThemeBase()265+ // Start with Base16 theme for limited terminal support
266+ theme := huh.ThemeBase16()
267+
268+ // Customize with bleu theme colors using ANSI 16 codes
269+ // ANSI Color mapping for bleu theme:
270+ // 0=black, 4=blue, 6=cyan, 7=white, 8=grey, 10=bright green, 12=bright blue, 14=bright cyan, 15=bright white
265 271
266 // Form and Group styles272 // Form and Group styles
267- theme.Form.Base = lipgloss.NewStyle()273+ theme.Group.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
268- theme.Group.Base = lipgloss.NewStyle()274+ theme.Group.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue
269- theme.Group.Title = TitleStyle
270- theme.Group.Description = SubtitleStyle
271 275
272 // Focused field styles276 // Focused field styles
273- theme.Focused.Base = lipgloss.NewStyle()277+ theme.Focused.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
274- theme.Focused.Title = lipgloss.NewStyle().Foreground(ColorBrightCyan).Bold(true)278+ theme.Focused.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue
275- theme.Focused.Description = lipgloss.NewStyle().Foreground(ColorPureBlue)279+ theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ")
276- theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(ColorBrightCyan).SetString("> ")280+ theme.Focused.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White
277- theme.Focused.Option = lipgloss.NewStyle().Foreground(ColorPrimaryText)281+ theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ")
278- theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(ColorBrightCyan).SetString("> ")282+ theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan
279- theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(ColorBrightCyan)283+ theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).SetString("✓ ") // Bright green
280- theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(ColorSuccessGreen).SetString("✓ ")284+ theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White
281- theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(ColorPrimaryText)285+ theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("○ ") // Grey
282- theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(ColorDimmedText).SetString("○ ")286+
283- theme.Focused.FocusedButton = lipgloss.NewStyle().Foreground(ColorPureWhite).Background(ColorOceanBlue).Padding(0, 2).Bold(true)287+ // Button styles with explicit background colors for TTY compatibility
284- theme.Focused.BlurredButton = lipgloss.NewStyle().Foreground(ColorDimmedText).Padding(0, 2)288+ // Active/focused button (currently selected - e.g., "Yes" when on Yes)
285-289+ theme.Focused.FocusedButton = lipgloss.NewStyle().
286- // Blurred field styles290+ Foreground(lipgloss.Color("15")). // Bright white text
287- theme.Blurred.Base = lipgloss.NewStyle()291+ Background(lipgloss.Color("4")). // Blue background
288- theme.Blurred.Title = lipgloss.NewStyle().Foreground(ColorDimmedText)292+ Padding(0, 2).
289- theme.Blurred.Description = lipgloss.NewStyle().Foreground(ColorDimmedText)293+ Bold(true)
290- theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(ColorDimmedText).SetString("> ")294+
291- theme.Blurred.Option = lipgloss.NewStyle().Foreground(ColorDimmedText)295+ // Inactive/blurred button (not selected - e.g., "No" when on Yes)
296+ theme.Focused.BlurredButton = lipgloss.NewStyle().
297+ Foreground(lipgloss.Color("7")). // White text
298+ Background(lipgloss.Color("0")). // Black background
299+ Padding(0, 2)
300+
301+ // Blurred field styles (when entire field loses focus)
302+ theme.Blurred.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
303+ theme.Blurred.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
304+ theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("> ")
305+ theme.Blurred.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
306+
307+ // Override buttons in Blurred state too (inherited from Focused in Base16)
308+ theme.Blurred.FocusedButton = theme.Focused.FocusedButton
309+ theme.Blurred.BlurredButton = theme.Focused.BlurredButton
292 310
293 // Text input styles311 // Text input styles
294- theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(ColorBrightCyan)312+ theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan
295- theme.Focused.TextInput.Placeholder = PlaceholderStyle313+ theme.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey
296- theme.Focused.TextInput.Prompt = PromptStyle314+ theme.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan
297 315
298 // Error styles316 // Error styles
299- theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(ColorSoftRed).SetString("✗ ")317+ theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).SetString("✗ ") // Bright red
300- theme.Focused.ErrorMessage = ErrorStyle318+ theme.Focused.ErrorMessage = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Bright red
301 319
302 return theme320 return theme
303 }321 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close