detached ea4c673

feat(tui,install): embed limine logo, CachyOS host setup, flat repo nav, unified hints, nav fixes

commit
ea4c67306836c2871eb09a547c7f54c1f0c4664f
author
brice · · 2026-02-27 17:49 UTC
parent
fad4841
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +369 -107
A assets/arch-logo.png +0 -0 binary file: no text diff

binary file: no text diff

A assets/gen-limine-logo.py +113 -0
@@ -0,0 +1,113 @@
1+#!/usr/bin/env python3
2+"""
3+Generate arch-logo.png for Limine bootloader wallpaper.
4+
5+Renders the Arch ASCII logo with the same cyan→slate-blue gradient used in
6+hyprlock.conf, on a deep navy background. Output is sized for 1920x1080.
7+
8+Usage: python3 gen-limine-logo.py [--out path/to/arch-logo.png]
9+"""
10+
11+import sys
12+import os
13+import argparse
14+from PIL import Image, ImageDraw, ImageFont
15+
16+LOGO_LINES = [
17+ " -`",
18+ " .o+`",
19+ " `ooo/",
20+ " `+oooo:",
21+ " `+oooooo:",
22+ " -+oooooo+:",
23+ " `/:-:++oooo+:",
24+ " `/++++/+++++++:",
25+ " `/++++++++++++++:",
26+ " `/+++ooooooooooooo/`",
27+ " ./ooosssso++osssssso+`",
28+ " .oossssso-````/ossssss+`",
29+ " -osssssso. :ssssssso.",
30+ " :osssssss/ osssso+++.",
31+ " /ossssssss/ +ssssooo/-",
32+ " `/ossssso+/:- -:/+osssso+-",
33+ " `+sso+:-` `.-/+oso:",
34+ " `++:. `-/+/",
35+ " .` `",
36+]
37+
38+# Gradient: cyan at top → slate-blue at bottom (matching hyprlock.conf)
39+GRADIENT_COLORS = [
40+ (0, 212, 255), # line 0 - bright cyan
41+ (0, 212, 255), # line 1
42+ (0, 212, 255), # line 2
43+ (0, 212, 255), # line 3
44+ (0, 196, 239), # line 4
45+ (0, 180, 223), # line 5
46+ (34, 164, 207), # line 6
47+ (68, 148, 191), # line 7
48+ (85, 136, 204), # line 8
49+ (85, 128, 192), # line 9
50+ (80, 120, 180), # line 10
51+ (74, 123, 167), # line 11
52+ (74, 123, 167), # line 12
53+ (90, 122, 154), # line 13
54+ (96, 122, 148), # line 14
55+ (101, 122, 144), # line 15
56+ (106, 122, 140), # line 16
57+ (110, 122, 136), # line 17
58+ (112, 128, 144), # line 18
59+]
60+
61+# Background: deep navy from hyprlock
62+BG_COLOR = (5, 10, 20)
63+
64+FONT_SIZE = 36
65+LINE_SPACING = 42 # px between lines
66+PADDING = 40 # px padding around the logo content
67+
68+FONT_PATH = "/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Thin.ttf"
69+FONT_PATH_FALLBACK = "/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf"
70+
71+
72+def main():
73+ parser = argparse.ArgumentParser()
74+ parser.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "arch-logo.png"))
75+ args = parser.parse_args()
76+
77+ # Load font
78+ font_path = FONT_PATH if os.path.exists(FONT_PATH) else FONT_PATH_FALLBACK
79+ try:
80+ font = ImageFont.truetype(font_path, FONT_SIZE)
81+ except Exception as e:
82+ print(f"Failed to load font {font_path}: {e}", file=sys.stderr)
83+ sys.exit(1)
84+
85+ # Measure block dimensions using a throw-away draw surface
86+ tmp = Image.new("RGB", (4000, 2000), BG_COLOR)
87+ tmp_draw = ImageDraw.Draw(tmp)
88+ widths = [tmp_draw.textbbox((0, 0), line, font=font)[2] for line in LOGO_LINES]
89+ max_w = max(widths)
90+ total_h = len(LOGO_LINES) * LINE_SPACING
91+
92+ # Canvas is just the logo + padding — Limine centers it on screen
93+ img_w = int(max_w) + PADDING * 2
94+ img_h = int(total_h) + PADDING * 2
95+
96+ img = Image.new("RGB", (img_w, img_h), BG_COLOR)
97+ draw = ImageDraw.Draw(img)
98+
99+ for i, line in enumerate(LOGO_LINES):
100+ color = GRADIENT_COLORS[i] if i < len(GRADIENT_COLORS) else GRADIENT_COLORS[-1]
101+ y = PADDING + i * LINE_SPACING
102+ draw.text((PADDING, y), line, font=font, fill=color)
103+
104+ # Quantize to a small palette (no dithering) — cuts file size dramatically
105+ # while preserving the gradient colors faithfully at boot resolution.
106+ img = img.quantize(colors=32, dither=Image.Dither.NONE)
107+ img.save(args.out, "PNG", optimize=True)
108+ size_kb = os.path.getsize(args.out) / 1024
109+ print(f"Generated: {args.out} ({img_w}x{img_h}, {size_kb:.1f}K)")
110+
111+
112+if __name__ == "__main__":
113+ main()
@@ -0,0 +1,113 @@
1+#!/usr/bin/env python3
2+"""
3+Generate arch-logo.png for Limine bootloader wallpaper.
4+
5+Renders the Arch ASCII logo with the same cyan→slate-blue gradient used in
6+hyprlock.conf, on a deep navy background. Output is sized for 1920x1080.
7+
8+Usage: python3 gen-limine-logo.py [--out path/to/arch-logo.png]
9+"""
10+
11+import sys
12+import os
13+import argparse
14+from PIL import Image, ImageDraw, ImageFont
15+
16+LOGO_LINES = [
17+ " -`",
18+ " .o+`",
19+ " `ooo/",
20+ " `+oooo:",
21+ " `+oooooo:",
22+ " -+oooooo+:",
23+ " `/:-:++oooo+:",
24+ " `/++++/+++++++:",
25+ " `/++++++++++++++:",
26+ " `/+++ooooooooooooo/`",
27+ " ./ooosssso++osssssso+`",
28+ " .oossssso-````/ossssss+`",
29+ " -osssssso. :ssssssso.",
30+ " :osssssss/ osssso+++.",
31+ " /ossssssss/ +ssssooo/-",
32+ " `/ossssso+/:- -:/+osssso+-",
33+ " `+sso+:-` `.-/+oso:",
34+ " `++:. `-/+/",
35+ " .` `",
36+]
37+
38+# Gradient: cyan at top → slate-blue at bottom (matching hyprlock.conf)
39+GRADIENT_COLORS = [
40+ (0, 212, 255), # line 0 - bright cyan
41+ (0, 212, 255), # line 1
42+ (0, 212, 255), # line 2
43+ (0, 212, 255), # line 3
44+ (0, 196, 239), # line 4
45+ (0, 180, 223), # line 5
46+ (34, 164, 207), # line 6
47+ (68, 148, 191), # line 7
48+ (85, 136, 204), # line 8
49+ (85, 128, 192), # line 9
50+ (80, 120, 180), # line 10
51+ (74, 123, 167), # line 11
52+ (74, 123, 167), # line 12
53+ (90, 122, 154), # line 13
54+ (96, 122, 148), # line 14
55+ (101, 122, 144), # line 15
56+ (106, 122, 140), # line 16
57+ (110, 122, 136), # line 17
58+ (112, 128, 144), # line 18
59+]
60+
61+# Background: deep navy from hyprlock
62+BG_COLOR = (5, 10, 20)
63+
64+FONT_SIZE = 36
65+LINE_SPACING = 42 # px between lines
66+PADDING = 40 # px padding around the logo content
67+
68+FONT_PATH = "/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Thin.ttf"
69+FONT_PATH_FALLBACK = "/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf"
70+
71+
72+def main():
73+ parser = argparse.ArgumentParser()
74+ parser.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "arch-logo.png"))
75+ args = parser.parse_args()
76+
77+ # Load font
78+ font_path = FONT_PATH if os.path.exists(FONT_PATH) else FONT_PATH_FALLBACK
79+ try:
80+ font = ImageFont.truetype(font_path, FONT_SIZE)
81+ except Exception as e:
82+ print(f"Failed to load font {font_path}: {e}", file=sys.stderr)
83+ sys.exit(1)
84+
85+ # Measure block dimensions using a throw-away draw surface
86+ tmp = Image.new("RGB", (4000, 2000), BG_COLOR)
87+ tmp_draw = ImageDraw.Draw(tmp)
88+ widths = [tmp_draw.textbbox((0, 0), line, font=font)[2] for line in LOGO_LINES]
89+ max_w = max(widths)
90+ total_h = len(LOGO_LINES) * LINE_SPACING
91+
92+ # Canvas is just the logo + padding — Limine centers it on screen
93+ img_w = int(max_w) + PADDING * 2
94+ img_h = int(total_h) + PADDING * 2
95+
96+ img = Image.new("RGB", (img_w, img_h), BG_COLOR)
97+ draw = ImageDraw.Draw(img)
98+
99+ for i, line in enumerate(LOGO_LINES):
100+ color = GRADIENT_COLORS[i] if i < len(GRADIENT_COLORS) else GRADIENT_COLORS[-1]
101+ y = PADDING + i * LINE_SPACING
102+ draw.text((PADDING, y), line, font=font, fill=color)
103+
104+ # Quantize to a small palette (no dithering) — cuts file size dramatically
105+ # while preserving the gradient colors faithfully at boot resolution.
106+ img = img.quantize(colors=32, dither=Image.Dither.NONE)
107+ img.save(args.out, "PNG", optimize=True)
108+ size_kb = os.path.getsize(args.out) / 1024
109+ print(f"Generated: {args.out} ({img_w}x{img_h}, {size_kb:.1f}K)")
110+
111+
112+if __name__ == "__main__":
113+ main()
M internal/application/handlers/install_base_handler.go +91 -0
@@ -80,6 +80,15 @@ func (h *InstallBaseHandler) Handle(ctx context.Context, cmd commands.InstallBas
8080 h.logger.Info("Adding additional packages", "count", len(cmd.Packages))
8181 }
8282
83+ // For CachyOS kernel: add the repo to the live host before pacstrap
84+ if cmd.KernelVariant == packages.KernelCachyOS {
85+ if err := h.setupCachyOSOnHost(ctx); err != nil {
86+ h.logger.Error("Failed to setup CachyOS repo on host", "error", err)
87+ result.ErrorDetail = fmt.Sprintf("Failed to setup CachyOS repo on host: %v", err)
88+ return result, err
89+ }
90+ }
91+
8392 h.logger.Info("Installing base packages", "count", len(basePackages))
8493 args := append([]string{cmd.MountPoint}, basePackages...)
8594 if _, err := h.cmdExec.Execute(ctx, "pacstrap", args...); err != nil {
@@ -109,6 +118,88 @@ func (h *InstallBaseHandler) Handle(ctx context.Context, cmd commands.InstallBas
109118 return result, nil
110119 }
111120
121+// setupCachyOSOnHost configures the CachyOS repo on the live host so that
122+// pacstrap can resolve linux-cachyos. Mirrors the working bash approach:
123+// import+sign key, write mirrorlist manually (no versioned package URLs),
124+// patch pacman.conf, then sync.
125+func (h *InstallBaseHandler) setupCachyOSOnHost(ctx context.Context) error {
126+ h.logger.Info("Setting up CachyOS repo on host for pacstrap")
127+
128+ const (
129+ cachyOSKeyID = "F3B607488DB35A47"
130+ keyserver = "keyserver.ubuntu.com"
131+ hostPacmanConf = "/etc/pacman.conf"
132+ cachyOSMirrorlistPath = "/etc/pacman.d/cachyos-mirrorlist"
133+ cachyOSMirrorlist = "## CachyOS mirrorlist\nServer = https://mirror.cachyos.org/repo/$arch/$repo\n"
134+ )
135+
136+ // Only fetch from keyserver if key is not already present.
137+ if _, err := h.cmdExec.Execute(ctx, "pacman-key", "--list-keys", cachyOSKeyID); err != nil {
138+ if _, err := h.cmdExec.Execute(ctx, "pacman-key", "--recv-keys",
139+ cachyOSKeyID, "--keyserver", keyserver); err != nil {
140+ return fmt.Errorf("failed to receive CachyOS key on host: %w", err)
141+ }
142+ }
143+
144+ // Local-sign the key so pacman trusts packages from this unofficial repo.
145+ if _, err := h.cmdExec.Execute(ctx, "pacman-key", "--lsign-key", cachyOSKeyID); err != nil {
146+ return fmt.Errorf("failed to sign CachyOS key on host: %w", err)
147+ }
148+
149+ // Write static mirrorlist — no versioned package URLs that can go stale.
150+ if err := h.fs.WriteFile(cachyOSMirrorlistPath, []byte(cachyOSMirrorlist), 0644); err != nil {
151+ return fmt.Errorf("failed to write host CachyOS mirrorlist: %w", err)
152+ }
153+
154+ confBytes, err := h.fs.ReadFile(hostPacmanConf)
155+ if err != nil {
156+ return fmt.Errorf("failed to read host pacman.conf: %w", err)
157+ }
158+ conf := ensureCachyOSHostRepo(string(confBytes))
159+ if err := h.fs.WriteFile(hostPacmanConf, []byte(conf), 0644); err != nil {
160+ return fmt.Errorf("failed to write host pacman.conf: %w", err)
161+ }
162+
163+ if _, err := h.cmdExec.Execute(ctx, "pacman", "-Sy", "--noconfirm"); err != nil {
164+ return fmt.Errorf("failed to sync host pacman repos: %w", err)
165+ }
166+
167+ h.logger.Info("CachyOS repo ready on host")
168+ return nil
169+}
170+
171+func ensureCachyOSHostRepo(conf string) string {
172+ if strings.Contains(conf, "[cachyos]") {
173+ return conf
174+ }
175+
176+ conf = strings.TrimRight(conf, "\n")
177+ if conf == "" {
178+ return "[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"
179+ }
180+
181+ block := []string{
182+ "# CachyOS repositories",
183+ "[cachyos]",
184+ "Include = /etc/pacman.d/cachyos-mirrorlist",
185+ "",
186+ }
187+
188+ lines := strings.Split(conf, "\n")
189+ for i, line := range lines {
190+ if strings.TrimSpace(line) == "[core]" {
191+ out := make([]string, 0, len(lines)+len(block))
192+ out = append(out, lines[:i]...)
193+ out = append(out, block...)
194+ out = append(out, lines[i:]...)
195+ return strings.Join(out, "\n") + "\n"
196+ }
197+ }
198+
199+ // Fallback: append if [core] section is not found.
200+ return conf + "\n[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"
201+}
202+
112203 // loadBasePackages reads the base package list from file
113204 func (h *InstallBaseHandler) loadBasePackages() ([]string, error) {
114205 // Read from install directory (downloaded during bootstrap)
@@ -80,6 +80,15 @@ func (h *InstallBaseHandler) Handle(ctx context.Context, cmd commands.InstallBas
80 h.logger.Info("Adding additional packages", "count", len(cmd.Packages))80 h.logger.Info("Adding additional packages", "count", len(cmd.Packages))
81 }81 }
82 82
83+ // For CachyOS kernel: add the repo to the live host before pacstrap
84+ if cmd.KernelVariant == packages.KernelCachyOS {
85+ if err := h.setupCachyOSOnHost(ctx); err != nil {
86+ h.logger.Error("Failed to setup CachyOS repo on host", "error", err)
87+ result.ErrorDetail = fmt.Sprintf("Failed to setup CachyOS repo on host: %v", err)
88+ return result, err
89+ }
90+ }
91+
83 h.logger.Info("Installing base packages", "count", len(basePackages))92 h.logger.Info("Installing base packages", "count", len(basePackages))
84 args := append([]string{cmd.MountPoint}, basePackages...)93 args := append([]string{cmd.MountPoint}, basePackages...)
85 if _, err := h.cmdExec.Execute(ctx, "pacstrap", args...); err != nil {94 if _, err := h.cmdExec.Execute(ctx, "pacstrap", args...); err != nil {
@@ -109,6 +118,88 @@ func (h *InstallBaseHandler) Handle(ctx context.Context, cmd commands.InstallBas
109 return result, nil118 return result, nil
110 }119 }
111 120
121+// setupCachyOSOnHost configures the CachyOS repo on the live host so that
122+// pacstrap can resolve linux-cachyos. Mirrors the working bash approach:
123+// import+sign key, write mirrorlist manually (no versioned package URLs),
124+// patch pacman.conf, then sync.
125+func (h *InstallBaseHandler) setupCachyOSOnHost(ctx context.Context) error {
126+ h.logger.Info("Setting up CachyOS repo on host for pacstrap")
127+
128+ const (
129+ cachyOSKeyID = "F3B607488DB35A47"
130+ keyserver = "keyserver.ubuntu.com"
131+ hostPacmanConf = "/etc/pacman.conf"
132+ cachyOSMirrorlistPath = "/etc/pacman.d/cachyos-mirrorlist"
133+ cachyOSMirrorlist = "## CachyOS mirrorlist\nServer = https://mirror.cachyos.org/repo/$arch/$repo\n"
134+ )
135+
136+ // Only fetch from keyserver if key is not already present.
137+ if _, err := h.cmdExec.Execute(ctx, "pacman-key", "--list-keys", cachyOSKeyID); err != nil {
138+ if _, err := h.cmdExec.Execute(ctx, "pacman-key", "--recv-keys",
139+ cachyOSKeyID, "--keyserver", keyserver); err != nil {
140+ return fmt.Errorf("failed to receive CachyOS key on host: %w", err)
141+ }
142+ }
143+
144+ // Local-sign the key so pacman trusts packages from this unofficial repo.
145+ if _, err := h.cmdExec.Execute(ctx, "pacman-key", "--lsign-key", cachyOSKeyID); err != nil {
146+ return fmt.Errorf("failed to sign CachyOS key on host: %w", err)
147+ }
148+
149+ // Write static mirrorlist — no versioned package URLs that can go stale.
150+ if err := h.fs.WriteFile(cachyOSMirrorlistPath, []byte(cachyOSMirrorlist), 0644); err != nil {
151+ return fmt.Errorf("failed to write host CachyOS mirrorlist: %w", err)
152+ }
153+
154+ confBytes, err := h.fs.ReadFile(hostPacmanConf)
155+ if err != nil {
156+ return fmt.Errorf("failed to read host pacman.conf: %w", err)
157+ }
158+ conf := ensureCachyOSHostRepo(string(confBytes))
159+ if err := h.fs.WriteFile(hostPacmanConf, []byte(conf), 0644); err != nil {
160+ return fmt.Errorf("failed to write host pacman.conf: %w", err)
161+ }
162+
163+ if _, err := h.cmdExec.Execute(ctx, "pacman", "-Sy", "--noconfirm"); err != nil {
164+ return fmt.Errorf("failed to sync host pacman repos: %w", err)
165+ }
166+
167+ h.logger.Info("CachyOS repo ready on host")
168+ return nil
169+}
170+
171+func ensureCachyOSHostRepo(conf string) string {
172+ if strings.Contains(conf, "[cachyos]") {
173+ return conf
174+ }
175+
176+ conf = strings.TrimRight(conf, "\n")
177+ if conf == "" {
178+ return "[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"
179+ }
180+
181+ block := []string{
182+ "# CachyOS repositories",
183+ "[cachyos]",
184+ "Include = /etc/pacman.d/cachyos-mirrorlist",
185+ "",
186+ }
187+
188+ lines := strings.Split(conf, "\n")
189+ for i, line := range lines {
190+ if strings.TrimSpace(line) == "[core]" {
191+ out := make([]string, 0, len(lines)+len(block))
192+ out = append(out, lines[:i]...)
193+ out = append(out, block...)
194+ out = append(out, lines[i:]...)
195+ return strings.Join(out, "\n") + "\n"
196+ }
197+ }
198+
199+ // Fallback: append if [core] section is not found.
200+ return conf + "\n[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"
201+}
202+
112 // loadBasePackages reads the base package list from file203 // loadBasePackages reads the base package list from file
113 func (h *InstallBaseHandler) loadBasePackages() ([]string, error) {204 func (h *InstallBaseHandler) loadBasePackages() ([]string, error) {
114 // Read from install directory (downloaded during bootstrap)205 // Read from install directory (downloaded during bootstrap)
M internal/application/handlers/post_install_handler.go +2 -6
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/archup/internal/application/dto"
1212 "github.com/bnema/archup/internal/config"
1313 "github.com/bnema/archup/internal/domain/ports"
14+ "github.com/bnema/archup/internal/ui/assets"
1415 )
1516
1617 // PostInstallHandler handles post-installation tasks
@@ -159,13 +160,8 @@ func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, user
159160 }
160161
161162 func (h *PostInstallHandler) installLimineLogo(mountPoint string) error {
162- logoContent, err := h.downloadTemplate(config.ArchLogoURL)
163- if err != nil {
164- return err
165- }
166-
167163 logoPath := filepath.Join(mountPoint, "boot", "arch-logo.png")
168- if err := h.fs.WriteFile(logoPath, logoContent, 0644); err != nil {
164+ if err := h.fs.WriteFile(logoPath, assets.LimineLogo, 0644); err != nil {
169165 return fmt.Errorf("failed to write Limine logo: %w", err)
170166 }
171167
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/archup/internal/application/dto"11 "github.com/bnema/archup/internal/application/dto"
12 "github.com/bnema/archup/internal/config"12 "github.com/bnema/archup/internal/config"
13 "github.com/bnema/archup/internal/domain/ports"13 "github.com/bnema/archup/internal/domain/ports"
14+ "github.com/bnema/archup/internal/ui/assets"
14 )15 )
15 16
16 // PostInstallHandler handles post-installation tasks17 // PostInstallHandler handles post-installation tasks
@@ -159,13 +160,8 @@ func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, user
159 }160 }
160 161
161 func (h *PostInstallHandler) installLimineLogo(mountPoint string) error {162 func (h *PostInstallHandler) installLimineLogo(mountPoint string) error {
162- logoContent, err := h.downloadTemplate(config.ArchLogoURL)
163- if err != nil {
164- return err
165- }
166-
167 logoPath := filepath.Join(mountPoint, "boot", "arch-logo.png")163 logoPath := filepath.Join(mountPoint, "boot", "arch-logo.png")
168- if err := h.fs.WriteFile(logoPath, logoContent, 0644); err != nil {164+ if err := h.fs.WriteFile(logoPath, assets.LimineLogo, 0644); err != nil {
169 return fmt.Errorf("failed to write Limine logo: %w", err)165 return fmt.Errorf("failed to write Limine logo: %w", err)
170 }166 }
171 167
M internal/application/handlers/repos_handler.go +47 -11
@@ -203,22 +203,34 @@ func ensureChaoticRepo(conf string) string {
203203 return conf + chaotic
204204 }
205205
206+// setupCachyOSRepo configures the CachyOS repo inside the chroot. Mirrors the
207+// bash approach: import+sign key, write mirrorlist manually (no versioned
208+// package URLs), patch pacman.conf, then sync.
206209 func (h *ReposHandler) setupCachyOSRepo(ctx context.Context, mountPoint string) error {
207- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--recv-key",
208- "F3B607488DB35A47", "--keyserver", "keyserver.ubuntu.com"); err != nil {
209- return fmt.Errorf("failed to receive CachyOS key: %w", err)
210+ const (
211+ cachyOSKeyID = "F3B607488DB35A47"
212+ keyserver = "keyserver.ubuntu.com"
213+ cachyOSMirrorlist = "## CachyOS mirrorlist\nServer = https://mirror.cachyos.org/repo/$arch/$repo\n"
214+ )
215+
216+ // Only fetch from keyserver if key is not already present in chroot.
217+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--list-keys", cachyOSKeyID); err != nil {
218+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--recv-keys",
219+ cachyOSKeyID, "--keyserver", keyserver); err != nil {
220+ return fmt.Errorf("failed to receive CachyOS key: %w", err)
221+ }
210222 }
211- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--lsign-key",
212- "F3B607488DB35A47"); err != nil {
223+
224+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--lsign-key", cachyOSKeyID); err != nil {
213225 return fmt.Errorf("failed to sign CachyOS key: %w", err)
214226 }
215- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman", "-U", "--noconfirm",
216- "https://mirror.cachyos.org/repo/x86_64/cachyos/cachyos-keyring-20240331-1-any.pkg.tar.zst",
217- "https://mirror.cachyos.org/repo/x86_64/cachyos/cachyos-mirrorlist-18-1-any.pkg.tar.zst",
218- "https://mirror.cachyos.org/repo/x86_64/cachyos/cachyos-v3-mirrorlist-18-1-any.pkg.tar.zst",
219- "https://mirror.cachyos.org/repo/x86_64/cachyos/cachyos-v4-mirrorlist-6-1-any.pkg.tar.zst"); err != nil {
220- return fmt.Errorf("failed to install CachyOS packages: %w", err)
227+
228+ // Write static mirrorlist — no versioned package URLs that can go stale.
229+ mirrorlistPath := filepath.Join(mountPoint, "etc", "pacman.d", "cachyos-mirrorlist")
230+ if err := h.fs.WriteFile(mirrorlistPath, []byte(cachyOSMirrorlist), 0644); err != nil {
231+ return fmt.Errorf("failed to write CachyOS mirrorlist: %w", err)
221232 }
233+
222234 pacmanConfPath := filepath.Join(mountPoint, "etc", "pacman.conf")
223235 confBytes, err := h.fs.ReadFile(pacmanConfPath)
224236 if err != nil {
@@ -232,6 +244,30 @@ func ensureCachyOSRepo(conf string) string {
232244 if strings.Contains(conf, "[cachyos]") {
233245 return conf
234246 }
247+
235248 conf = strings.TrimRight(conf, "\n")
249+ if conf == "" {
250+ return "[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"
251+ }
252+
253+ block := []string{
254+ "# CachyOS repositories",
255+ "[cachyos]",
256+ "Include = /etc/pacman.d/cachyos-mirrorlist",
257+ "",
258+ }
259+
260+ lines := strings.Split(conf, "\n")
261+ for i, line := range lines {
262+ if strings.TrimSpace(line) == "[core]" {
263+ out := make([]string, 0, len(lines)+len(block))
264+ out = append(out, lines[:i]...)
265+ out = append(out, block...)
266+ out = append(out, lines[i:]...)
267+ return strings.Join(out, "\n") + "\n"
268+ }
269+ }
270+
271+ // Fallback: append if [core] section is not found.
236272 return conf + "\n[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"
237273 }
@@ -203,22 +203,34 @@ func ensureChaoticRepo(conf string) string {
203 return conf + chaotic203 return conf + chaotic
204 }204 }
205 205
206+// setupCachyOSRepo configures the CachyOS repo inside the chroot. Mirrors the
207+// bash approach: import+sign key, write mirrorlist manually (no versioned
208+// package URLs), patch pacman.conf, then sync.
206 func (h *ReposHandler) setupCachyOSRepo(ctx context.Context, mountPoint string) error {209 func (h *ReposHandler) setupCachyOSRepo(ctx context.Context, mountPoint string) error {
207- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--recv-key",210+ const (
208- "F3B607488DB35A47", "--keyserver", "keyserver.ubuntu.com"); err != nil {211+ cachyOSKeyID = "F3B607488DB35A47"
209- return fmt.Errorf("failed to receive CachyOS key: %w", err)212+ keyserver = "keyserver.ubuntu.com"
213+ cachyOSMirrorlist = "## CachyOS mirrorlist\nServer = https://mirror.cachyos.org/repo/$arch/$repo\n"
214+ )
215+
216+ // Only fetch from keyserver if key is not already present in chroot.
217+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--list-keys", cachyOSKeyID); err != nil {
218+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--recv-keys",
219+ cachyOSKeyID, "--keyserver", keyserver); err != nil {
220+ return fmt.Errorf("failed to receive CachyOS key: %w", err)
221+ }
210 }222 }
211- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--lsign-key",223+
212- "F3B607488DB35A47"); err != nil {224+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman-key", "--lsign-key", cachyOSKeyID); err != nil {
213 return fmt.Errorf("failed to sign CachyOS key: %w", err)225 return fmt.Errorf("failed to sign CachyOS key: %w", err)
214 }226 }
215- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman", "-U", "--noconfirm",227+
216- "https://mirror.cachyos.org/repo/x86_64/cachyos/cachyos-keyring-20240331-1-any.pkg.tar.zst",228+ // Write static mirrorlist — no versioned package URLs that can go stale.
217- "https://mirror.cachyos.org/repo/x86_64/cachyos/cachyos-mirrorlist-18-1-any.pkg.tar.zst",229+ mirrorlistPath := filepath.Join(mountPoint, "etc", "pacman.d", "cachyos-mirrorlist")
218- "https://mirror.cachyos.org/repo/x86_64/cachyos/cachyos-v3-mirrorlist-18-1-any.pkg.tar.zst",230+ if err := h.fs.WriteFile(mirrorlistPath, []byte(cachyOSMirrorlist), 0644); err != nil {
219- "https://mirror.cachyos.org/repo/x86_64/cachyos/cachyos-v4-mirrorlist-6-1-any.pkg.tar.zst"); err != nil {231+ return fmt.Errorf("failed to write CachyOS mirrorlist: %w", err)
220- return fmt.Errorf("failed to install CachyOS packages: %w", err)
221 }232 }
233+
222 pacmanConfPath := filepath.Join(mountPoint, "etc", "pacman.conf")234 pacmanConfPath := filepath.Join(mountPoint, "etc", "pacman.conf")
223 confBytes, err := h.fs.ReadFile(pacmanConfPath)235 confBytes, err := h.fs.ReadFile(pacmanConfPath)
224 if err != nil {236 if err != nil {
@@ -232,6 +244,30 @@ func ensureCachyOSRepo(conf string) string {
232 if strings.Contains(conf, "[cachyos]") {244 if strings.Contains(conf, "[cachyos]") {
233 return conf245 return conf
234 }246 }
247+
235 conf = strings.TrimRight(conf, "\n")248 conf = strings.TrimRight(conf, "\n")
249+ if conf == "" {
250+ return "[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"
251+ }
252+
253+ block := []string{
254+ "# CachyOS repositories",
255+ "[cachyos]",
256+ "Include = /etc/pacman.d/cachyos-mirrorlist",
257+ "",
258+ }
259+
260+ lines := strings.Split(conf, "\n")
261+ for i, line := range lines {
262+ if strings.TrimSpace(line) == "[core]" {
263+ out := make([]string, 0, len(lines)+len(block))
264+ out = append(out, lines[:i]...)
265+ out = append(out, block...)
266+ out = append(out, lines[i:]...)
267+ return strings.Join(out, "\n") + "\n"
268+ }
269+ }
270+
271+ // Fallback: append if [core] section is not found.
236 return conf + "\n[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"272 return conf + "\n[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n"
237 }273 }
M internal/config/config.go +0 -1
@@ -194,7 +194,6 @@ var PostBootScripts = []string{
194194 "blesh.sh",
195195 "cli-tools.sh",
196196 "dms-opt-in.sh",
197- "logo.txt",
198197 }
199198
200199 // Post-boot service template URL path
@@ -194,7 +194,6 @@ var PostBootScripts = []string{
194 "blesh.sh",194 "blesh.sh",
195 "cli-tools.sh",195 "cli-tools.sh",
196 "dms-opt-in.sh",196 "dms-opt-in.sh",
197- "logo.txt",
198 }197 }
199 198
200 // Post-boot service template URL path199 // Post-boot service template URL path
M internal/interfaces/tui/app.go +8 -5
@@ -320,6 +320,8 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
320320 switch msg.String() {
321321 case "ctrl+c":
322322 return a, tea.Quit
323+ case "esc", "backspace":
324+ return a.startAMDPStateSelection()
323325 case "up", "shift+tab":
324326 a.gpuModel.MoveUp()
325327 return a, nil
@@ -373,11 +375,9 @@ func (a *App) handleReposInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
373375 case "down":
374376 a.reposModel.MoveDown()
375377 return a, nil
376- case "tab":
377- a.reposModel.NextSection()
378- return a, nil
379- case "shift+tab":
380- a.reposModel.PrevSection()
378+ case "left", "right", " ":
379+ // Select the item under the cursor (radio within its section)
380+ a.reposModel.Select()
381381 return a, nil
382382 case "enter":
383383 a.formData.AURHelper = a.reposModel.SelectedAURHelper()
@@ -567,6 +567,9 @@ func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
567567 switch msg.String() {
568568 case "ctrl+c":
569569 return a, tea.Quit
570+ case "esc", "backspace":
571+ a.currentScreen = ScreenForm
572+ return a, nil
570573 case "up", "shift+tab":
571574 a.diskModel.MoveUp()
572575 return a, nil
@@ -320,6 +320,8 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
320 switch msg.String() {320 switch msg.String() {
321 case "ctrl+c":321 case "ctrl+c":
322 return a, tea.Quit322 return a, tea.Quit
323+ case "esc", "backspace":
324+ return a.startAMDPStateSelection()
323 case "up", "shift+tab":325 case "up", "shift+tab":
324 a.gpuModel.MoveUp()326 a.gpuModel.MoveUp()
325 return a, nil327 return a, nil
@@ -373,11 +375,9 @@ func (a *App) handleReposInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
373 case "down":375 case "down":
374 a.reposModel.MoveDown()376 a.reposModel.MoveDown()
375 return a, nil377 return a, nil
376- case "tab":378+ case "left", "right", " ":
377- a.reposModel.NextSection()379+ // Select the item under the cursor (radio within its section)
378- return a, nil380+ a.reposModel.Select()
379- case "shift+tab":
380- a.reposModel.PrevSection()
381 return a, nil381 return a, nil
382 case "enter":382 case "enter":
383 a.formData.AURHelper = a.reposModel.SelectedAURHelper()383 a.formData.AURHelper = a.reposModel.SelectedAURHelper()
@@ -567,6 +567,9 @@ func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
567 switch msg.String() {567 switch msg.String() {
568 case "ctrl+c":568 case "ctrl+c":
569 return a, tea.Quit569 return a, tea.Quit
570+ case "esc", "backspace":
571+ a.currentScreen = ScreenForm
572+ return a, nil
570 case "up", "shift+tab":573 case "up", "shift+tab":
571 a.diskModel.MoveUp()574 a.diskModel.MoveUp()
572 return a, nil575 return a, nil
M internal/interfaces/tui/models/repos.go +53 -42
@@ -17,13 +17,14 @@ type ChaoticOption struct {
1717 }
1818
1919 // ReposModelImpl holds AUR helper and Chaotic-AUR selection state.
20+// Navigation: ↑/↓ moves a single cursor across all items (crossing sections).
21+// ←/→ selects the item under the cursor (radio within its section).
2022 type ReposModelImpl struct {
2123 aurOptions []AURHelperOption
22- aurSelected int
24+ aurSelected int // which AUR item is selected (confirmed)
2325 chaoticOptions []ChaoticOption
24- chaoticSelected int
25- // focusSection: 0 = AUR helper, 1 = Chaotic-AUR
26- focusSection int
26+ chaoticSelected int // which Chaotic item is selected (confirmed)
27+ cursor int // flat index across all items (0..totalItems-1)
2728 }
2829
2930 // NewReposModel creates a new repos selection model.
@@ -57,24 +58,38 @@ func NewReposModel() *ReposModelImpl {
5758 aurSelected: aurSelected,
5859 chaoticOptions: chaoticOptions,
5960 chaoticSelected: chaoticSelected,
60- focusSection: 0,
61+ cursor: 0,
6162 }
6263 }
6364
65+// totalItems returns the total number of items across all sections.
66+func (rm *ReposModelImpl) totalItems() int {
67+ return len(rm.aurOptions) + len(rm.chaoticOptions)
68+}
69+
70+// CursorIndex returns the current flat cursor position.
71+func (rm *ReposModelImpl) CursorIndex() int { return rm.cursor }
72+
6473 // AUROptions returns the selectable AUR helper options.
6574 func (rm *ReposModelImpl) AUROptions() []AURHelperOption { return rm.aurOptions }
6675
67-// AURSelectedIndex returns the current AUR selection index.
76+// AURSelectedIndex returns the confirmed AUR selection index.
6877 func (rm *ReposModelImpl) AURSelectedIndex() int { return rm.aurSelected }
6978
7079 // ChaoticOptions returns the selectable Chaotic-AUR options.
7180 func (rm *ReposModelImpl) ChaoticOptions() []ChaoticOption { return rm.chaoticOptions }
7281
73-// ChaoticSelectedIndex returns the current Chaotic-AUR selection index.
82+// ChaoticSelectedIndex returns the confirmed Chaotic-AUR selection index.
7483 func (rm *ReposModelImpl) ChaoticSelectedIndex() int { return rm.chaoticSelected }
7584
76-// FocusSection returns the currently focused section (0=AUR, 1=Chaotic).
77-func (rm *ReposModelImpl) FocusSection() int { return rm.focusSection }
85+// FocusSection returns which section the cursor is in (0=AUR, 1=Chaotic).
86+// Kept for view compatibility.
87+func (rm *ReposModelImpl) FocusSection() int {
88+ if rm.cursor < len(rm.aurOptions) {
89+ return 0
90+ }
91+ return 1
92+}
7893
7994 // SelectedAURHelper returns the selected AUR helper value string.
8095 func (rm *ReposModelImpl) SelectedAURHelper() string {
@@ -98,50 +113,46 @@ func (rm *ReposModelImpl) SelectedChaoticEnabled() bool {
98113 return rm.chaoticOptions[rm.chaoticSelected].Enabled
99114 }
100115
101-// MoveUp moves selection up in the current section (wraps).
116+// MoveUp moves the cursor up (crosses section boundaries, no wrap).
102117 func (rm *ReposModelImpl) MoveUp() {
103- if rm.focusSection == 0 {
104- if len(rm.aurOptions) == 0 {
105- return
106- }
107- if rm.aurSelected == 0 {
108- rm.aurSelected = len(rm.aurOptions) - 1
109- } else {
110- rm.aurSelected--
111- }
112- } else {
113- if len(rm.chaoticOptions) == 0 {
114- return
115- }
116- if rm.chaoticSelected == 0 {
117- rm.chaoticSelected = len(rm.chaoticOptions) - 1
118- } else {
119- rm.chaoticSelected--
120- }
118+ if rm.cursor > 0 {
119+ rm.cursor--
121120 }
122121 }
123122
124-// MoveDown moves selection down in the current section (wraps).
123+// MoveDown moves the cursor down (crosses section boundaries, no wrap).
125124 func (rm *ReposModelImpl) MoveDown() {
126- if rm.focusSection == 0 {
127- if len(rm.aurOptions) == 0 {
128- return
129- }
130- rm.aurSelected = (rm.aurSelected + 1) % len(rm.aurOptions)
125+ if rm.cursor < rm.totalItems()-1 {
126+ rm.cursor++
127+ }
128+}
129+
130+// Select selects (radio) the item currently under the cursor within its section.
131+func (rm *ReposModelImpl) Select() {
132+ if rm.cursor < len(rm.aurOptions) {
133+ rm.aurSelected = rm.cursor
131134 } else {
132- if len(rm.chaoticOptions) == 0 {
133- return
134- }
135- rm.chaoticSelected = (rm.chaoticSelected + 1) % len(rm.chaoticOptions)
135+ rm.chaoticSelected = rm.cursor - len(rm.aurOptions)
136136 }
137137 }
138138
139-// NextSection moves focus to the next section.
139+// NextSection moves focus to the next section (kept for compatibility).
140140 func (rm *ReposModelImpl) NextSection() {
141- rm.focusSection = (rm.focusSection + 1) % 2
141+ rm.focusToSection(rm.FocusSection() + 1)
142142 }
143143
144-// PrevSection moves focus to the previous section.
144+// PrevSection moves focus to the previous section (kept for compatibility).
145145 func (rm *ReposModelImpl) PrevSection() {
146- rm.focusSection = (rm.focusSection - 1 + 2) % 2
146+ rm.focusToSection(rm.FocusSection() - 1)
147147 }
148+
149+func (rm *ReposModelImpl) focusToSection(s int) {
150+ if s <= 0 {
151+ rm.cursor = 0
152+ } else {
153+ rm.cursor = len(rm.aurOptions)
154+ }
155+}
156+
157+// SectionCount returns the total number of sections.
158+func (rm *ReposModelImpl) SectionCount() int { return 2 }
@@ -17,13 +17,14 @@ type ChaoticOption struct {
17 }17 }
18 18
19 // ReposModelImpl holds AUR helper and Chaotic-AUR selection state.19 // ReposModelImpl holds AUR helper and Chaotic-AUR selection state.
20+// Navigation: ↑/↓ moves a single cursor across all items (crossing sections).
21+// ←/→ selects the item under the cursor (radio within its section).
20 type ReposModelImpl struct {22 type ReposModelImpl struct {
21 aurOptions []AURHelperOption23 aurOptions []AURHelperOption
22- aurSelected int24+ aurSelected int // which AUR item is selected (confirmed)
23 chaoticOptions []ChaoticOption25 chaoticOptions []ChaoticOption
24- chaoticSelected int26+ chaoticSelected int // which Chaotic item is selected (confirmed)
25- // focusSection: 0 = AUR helper, 1 = Chaotic-AUR27+ cursor int // flat index across all items (0..totalItems-1)
26- focusSection int
27 }28 }
28 29
29 // NewReposModel creates a new repos selection model.30 // NewReposModel creates a new repos selection model.
@@ -57,24 +58,38 @@ func NewReposModel() *ReposModelImpl {
57 aurSelected: aurSelected,58 aurSelected: aurSelected,
58 chaoticOptions: chaoticOptions,59 chaoticOptions: chaoticOptions,
59 chaoticSelected: chaoticSelected,60 chaoticSelected: chaoticSelected,
60- focusSection: 0,61+ cursor: 0,
61 }62 }
62 }63 }
63 64
65+// totalItems returns the total number of items across all sections.
66+func (rm *ReposModelImpl) totalItems() int {
67+ return len(rm.aurOptions) + len(rm.chaoticOptions)
68+}
69+
70+// CursorIndex returns the current flat cursor position.
71+func (rm *ReposModelImpl) CursorIndex() int { return rm.cursor }
72+
64 // AUROptions returns the selectable AUR helper options.73 // AUROptions returns the selectable AUR helper options.
65 func (rm *ReposModelImpl) AUROptions() []AURHelperOption { return rm.aurOptions }74 func (rm *ReposModelImpl) AUROptions() []AURHelperOption { return rm.aurOptions }
66 75
67-// AURSelectedIndex returns the current AUR selection index.76+// AURSelectedIndex returns the confirmed AUR selection index.
68 func (rm *ReposModelImpl) AURSelectedIndex() int { return rm.aurSelected }77 func (rm *ReposModelImpl) AURSelectedIndex() int { return rm.aurSelected }
69 78
70 // ChaoticOptions returns the selectable Chaotic-AUR options.79 // ChaoticOptions returns the selectable Chaotic-AUR options.
71 func (rm *ReposModelImpl) ChaoticOptions() []ChaoticOption { return rm.chaoticOptions }80 func (rm *ReposModelImpl) ChaoticOptions() []ChaoticOption { return rm.chaoticOptions }
72 81
73-// ChaoticSelectedIndex returns the current Chaotic-AUR selection index.82+// ChaoticSelectedIndex returns the confirmed Chaotic-AUR selection index.
74 func (rm *ReposModelImpl) ChaoticSelectedIndex() int { return rm.chaoticSelected }83 func (rm *ReposModelImpl) ChaoticSelectedIndex() int { return rm.chaoticSelected }
75 84
76-// FocusSection returns the currently focused section (0=AUR, 1=Chaotic).85+// FocusSection returns which section the cursor is in (0=AUR, 1=Chaotic).
77-func (rm *ReposModelImpl) FocusSection() int { return rm.focusSection }86+// Kept for view compatibility.
87+func (rm *ReposModelImpl) FocusSection() int {
88+ if rm.cursor < len(rm.aurOptions) {
89+ return 0
90+ }
91+ return 1
92+}
78 93
79 // SelectedAURHelper returns the selected AUR helper value string.94 // SelectedAURHelper returns the selected AUR helper value string.
80 func (rm *ReposModelImpl) SelectedAURHelper() string {95 func (rm *ReposModelImpl) SelectedAURHelper() string {
@@ -98,50 +113,46 @@ func (rm *ReposModelImpl) SelectedChaoticEnabled() bool {
98 return rm.chaoticOptions[rm.chaoticSelected].Enabled113 return rm.chaoticOptions[rm.chaoticSelected].Enabled
99 }114 }
100 115
101-// MoveUp moves selection up in the current section (wraps).116+// MoveUp moves the cursor up (crosses section boundaries, no wrap).
102 func (rm *ReposModelImpl) MoveUp() {117 func (rm *ReposModelImpl) MoveUp() {
103- if rm.focusSection == 0 {118+ if rm.cursor > 0 {
104- if len(rm.aurOptions) == 0 {119+ rm.cursor--
105- return
106- }
107- if rm.aurSelected == 0 {
108- rm.aurSelected = len(rm.aurOptions) - 1
109- } else {
110- rm.aurSelected--
111- }
112- } else {
113- if len(rm.chaoticOptions) == 0 {
114- return
115- }
116- if rm.chaoticSelected == 0 {
117- rm.chaoticSelected = len(rm.chaoticOptions) - 1
118- } else {
119- rm.chaoticSelected--
120- }
121 }120 }
122 }121 }
123 122
124-// MoveDown moves selection down in the current section (wraps).123+// MoveDown moves the cursor down (crosses section boundaries, no wrap).
125 func (rm *ReposModelImpl) MoveDown() {124 func (rm *ReposModelImpl) MoveDown() {
126- if rm.focusSection == 0 {125+ if rm.cursor < rm.totalItems()-1 {
127- if len(rm.aurOptions) == 0 {126+ rm.cursor++
128- return127+ }
129- }128+}
130- rm.aurSelected = (rm.aurSelected + 1) % len(rm.aurOptions)129+
130+// Select selects (radio) the item currently under the cursor within its section.
131+func (rm *ReposModelImpl) Select() {
132+ if rm.cursor < len(rm.aurOptions) {
133+ rm.aurSelected = rm.cursor
131 } else {134 } else {
132- if len(rm.chaoticOptions) == 0 {135+ rm.chaoticSelected = rm.cursor - len(rm.aurOptions)
133- return
134- }
135- rm.chaoticSelected = (rm.chaoticSelected + 1) % len(rm.chaoticOptions)
136 }136 }
137 }137 }
138 138
139-// NextSection moves focus to the next section.139+// NextSection moves focus to the next section (kept for compatibility).
140 func (rm *ReposModelImpl) NextSection() {140 func (rm *ReposModelImpl) NextSection() {
141- rm.focusSection = (rm.focusSection + 1) % 2141+ rm.focusToSection(rm.FocusSection() + 1)
142 }142 }
143 143
144-// PrevSection moves focus to the previous section.144+// PrevSection moves focus to the previous section (kept for compatibility).
145 func (rm *ReposModelImpl) PrevSection() {145 func (rm *ReposModelImpl) PrevSection() {
146- rm.focusSection = (rm.focusSection - 1 + 2) % 2146+ rm.focusToSection(rm.FocusSection() - 1)
147 }147 }
148+
149+func (rm *ReposModelImpl) focusToSection(s int) {
150+ if s <= 0 {
151+ rm.cursor = 0
152+ } else {
153+ rm.cursor = len(rm.aurOptions)
154+ }
155+}
156+
157+// SectionCount returns the total number of sections.
158+func (rm *ReposModelImpl) SectionCount() int { return 2 }
M internal/interfaces/tui/views/amd_pstate.go +1 -1
@@ -70,7 +70,7 @@ func RenderAMDPStateSelection(am *models.AMDPStateModelImpl) string {
7070 }
7171
7272 b.WriteString("\n")
73- b.WriteString(info.Render("Up/Down Move | Enter Confirm | Esc Back | Ctrl+C Quit"))
73+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
7474
7575 return b.String()
7676 }
@@ -70,7 +70,7 @@ func RenderAMDPStateSelection(am *models.AMDPStateModelImpl) string {
70 }70 }
71 71
72 b.WriteString("\n")72 b.WriteString("\n")
73- b.WriteString(info.Render("Up/Down Move | Enter Confirm | Esc Back | Ctrl+C Quit"))73+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
74 74
75 return b.String()75 return b.String()
76 }76 }
M internal/interfaces/tui/views/dank_linux.go +1 -1
@@ -44,7 +44,7 @@ func RenderDankLinuxSelection(dm *models.DankLinuxModelImpl) string {
4444 }
4545
4646 b.WriteString("\n")
47- b.WriteString(info.Render("↑/↓ to select • enter to confirm"))
47+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
4848
4949 return b.String()
5050 }
@@ -44,7 +44,7 @@ func RenderDankLinuxSelection(dm *models.DankLinuxModelImpl) string {
44 }44 }
45 45
46 b.WriteString("\n")46 b.WriteString("\n")
47- b.WriteString(info.Render("↑/↓ to select • enter to confirm"))47+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
48 48
49 return b.String()49 return b.String()
50 }50 }
M internal/interfaces/tui/views/disk.go +1 -1
@@ -57,7 +57,7 @@ func RenderDiskSelection(dm *models.DiskModelImpl) string {
5757 b.WriteString("\n")
5858 b.WriteString(warningStyle.Render("⚠ WARNING: Selected disk will be completely erased!"))
5959 b.WriteString("\n\n")
60- b.WriteString(dimStyle.Render("↑↓ Navigate • Enter Select • Ctrl+C Quit"))
60+ b.WriteString(dimStyle.Render("↑/↓ navigate • enter select • esc back • ctrl+c quit"))
6161
6262 return b.String()
6363 }
@@ -57,7 +57,7 @@ func RenderDiskSelection(dm *models.DiskModelImpl) string {
57 b.WriteString("\n")57 b.WriteString("\n")
58 b.WriteString(warningStyle.Render("⚠ WARNING: Selected disk will be completely erased!"))58 b.WriteString(warningStyle.Render("⚠ WARNING: Selected disk will be completely erased!"))
59 b.WriteString("\n\n")59 b.WriteString("\n\n")
60- b.WriteString(dimStyle.Render("↑↓ Navigate • Enter Select • Ctrl+C Quit"))60+ b.WriteString(dimStyle.Render("↑/↓ navigate • enter select • esc back • ctrl+c quit"))
61 61
62 return b.String()62 return b.String()
63 }63 }
M internal/interfaces/tui/views/encryption.go +1 -1
@@ -42,7 +42,7 @@ func RenderEncryptionSelection(em *models.EncryptionModelImpl) string {
4242 }
4343
4444 b.WriteString("\n")
45- b.WriteString(info.Render("↑/↓ to select • enter to confirm • esc to go back"))
45+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
4646
4747 return b.String()
4848 }
@@ -42,7 +42,7 @@ func RenderEncryptionSelection(em *models.EncryptionModelImpl) string {
42 }42 }
43 43
44 b.WriteString("\n")44 b.WriteString("\n")
45- b.WriteString(info.Render("↑/↓ to select • enter to confirm • esc to go back"))45+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
46 46
47 return b.String()47 return b.String()
48 }48 }
M internal/interfaces/tui/views/gpu.go +1 -1
@@ -43,7 +43,7 @@ func RenderGPUSelection(gm *models.GPUModelImpl) string {
4343 }
4444
4545 b.WriteString("\n")
46- b.WriteString(info.Render("↑↓ Move • Enter Confirm • Ctrl+C Quit"))
46+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
4747
4848 return b.String()
4949 }
@@ -43,7 +43,7 @@ func RenderGPUSelection(gm *models.GPUModelImpl) string {
43 }43 }
44 44
45 b.WriteString("\n")45 b.WriteString("\n")
46- b.WriteString(info.Render("↑↓ Move • Enter Confirm • Ctrl+C Quit"))46+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
47 47
48 return b.String()48 return b.String()
49 }49 }
M internal/interfaces/tui/views/kernel.go +1 -1
@@ -44,7 +44,7 @@ func RenderKernelSelection(km *models.KernelModelImpl) string {
4444 }
4545
4646 b.WriteString("\n")
47- b.WriteString(info.Render("Up/Down Move | Enter Confirm | Esc Back | Ctrl+C Quit"))
47+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
4848
4949 return b.String()
5050 }
@@ -44,7 +44,7 @@ func RenderKernelSelection(km *models.KernelModelImpl) string {
44 }44 }
45 45
46 b.WriteString("\n")46 b.WriteString("\n")
47- b.WriteString(info.Render("Up/Down Move | Enter Confirm | Esc Back | Ctrl+C Quit"))47+ b.WriteString(info.Render("↑/↓ navigate • enter confirm • esc back • ctrl+c quit"))
48 48
49 return b.String()49 return b.String()
50 }50 }
M internal/interfaces/tui/views/repos.go +43 -35
@@ -13,71 +13,79 @@ func RenderReposSelection(rm *models.ReposModelImpl) string {
1313
1414 title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
1515 info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
1716 section := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))
18- activeSection := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10"))
1917 desc := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Faint(true)
2018
19+ // cursor = highlighted row; selected = confirmed choice (shown with [x])
20+ cursorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
21+ selectedMark := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
22+ normalMark := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
23+
2124 b.WriteString("\n")
2225 b.WriteString(title.Render("Repository Configuration"))
2326 b.WriteString("\n\n")
2427
2528 // AUR Helper section
26- aurHeaderStyle := section
27- if rm.FocusSection() == 0 {
28- aurHeaderStyle = activeSection
29- }
30- b.WriteString(aurHeaderStyle.Render("AUR Helper"))
29+ b.WriteString(section.Render("AUR Helper"))
3130 b.WriteString("\n")
3231
3332 for i, option := range rm.AUROptions() {
34- prefix := " "
35- style := lipgloss.NewStyle()
36- labelStyle := lipgloss.NewStyle()
37-
38- if rm.FocusSection() == 0 && i == rm.AURSelectedIndex() {
39- prefix = "> "
40- style = active
41- labelStyle = active
33+ flatIdx := i
34+ isCursor := rm.CursorIndex() == flatIdx
35+ isSelected := rm.AURSelectedIndex() == i
36+
37+ // Selection marker
38+ if isSelected {
39+ b.WriteString(selectedMark.Render(" [x] "))
40+ } else {
41+ b.WriteString(normalMark.Render(" [ ] "))
4242 }
4343
44- b.WriteString(style.Render(prefix))
45- b.WriteString(labelStyle.Render(option.Label))
44+ // Label
45+ label := option.Label
46+ if isCursor {
47+ b.WriteString(cursorStyle.Render("> " + label))
48+ } else {
49+ b.WriteString(label)
50+ }
4651 b.WriteString("\n")
47- b.WriteString(desc.Render(" " + option.Description))
52+ b.WriteString(desc.Render(" " + option.Description))
4853 b.WriteString("\n")
4954 }
5055
5156 b.WriteString("\n")
5257
5358 // Chaotic-AUR section
54- chaoticHeaderStyle := section
55- if rm.FocusSection() == 1 {
56- chaoticHeaderStyle = activeSection
57- }
58- b.WriteString(chaoticHeaderStyle.Render("Chaotic-AUR"))
59+ b.WriteString(section.Render("Chaotic-AUR"))
5960 b.WriteString("\n")
6061
62+ aurLen := len(rm.AUROptions())
6163 for i, option := range rm.ChaoticOptions() {
62- prefix := " "
63- style := lipgloss.NewStyle()
64- labelStyle := lipgloss.NewStyle()
65-
66- if rm.FocusSection() == 1 && i == rm.ChaoticSelectedIndex() {
67- prefix = "> "
68- style = active
69- labelStyle = active
64+ flatIdx := aurLen + i
65+ isCursor := rm.CursorIndex() == flatIdx
66+ isSelected := rm.ChaoticSelectedIndex() == i
67+
68+ // Selection marker
69+ if isSelected {
70+ b.WriteString(selectedMark.Render(" [x] "))
71+ } else {
72+ b.WriteString(normalMark.Render(" [ ] "))
7073 }
7174
72- b.WriteString(style.Render(prefix))
73- b.WriteString(labelStyle.Render(option.Label))
75+ // Label
76+ label := option.Label
77+ if isCursor {
78+ b.WriteString(cursorStyle.Render("> " + label))
79+ } else {
80+ b.WriteString(label)
81+ }
7482 b.WriteString("\n")
75- b.WriteString(desc.Render(" " + option.Description))
83+ b.WriteString(desc.Render(" " + option.Description))
7684 b.WriteString("\n")
7785 }
7886
7987 b.WriteString("\n")
80- b.WriteString(info.Render("↑/↓ to select • tab to switch section • enter to confirm • esc to go back"))
88+ b.WriteString(info.Render("↑/↓ move • ←/→ or space select • enter confirm • esc back"))
8189
8290 return b.String()
8391 }
@@ -13,71 +13,79 @@ func RenderReposSelection(rm *models.ReposModelImpl) string {
13 13
14 title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))14 title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15 info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))15 info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17 section := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))16 section := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))
18- activeSection := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10"))
19 desc := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Faint(true)17 desc := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Faint(true)
20 18
19+ // cursor = highlighted row; selected = confirmed choice (shown with [x])
20+ cursorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
21+ selectedMark := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
22+ normalMark := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
23+
21 b.WriteString("\n")24 b.WriteString("\n")
22 b.WriteString(title.Render("Repository Configuration"))25 b.WriteString(title.Render("Repository Configuration"))
23 b.WriteString("\n\n")26 b.WriteString("\n\n")
24 27
25 // AUR Helper section28 // AUR Helper section
26- aurHeaderStyle := section29+ b.WriteString(section.Render("AUR Helper"))
27- if rm.FocusSection() == 0 {
28- aurHeaderStyle = activeSection
29- }
30- b.WriteString(aurHeaderStyle.Render("AUR Helper"))
31 b.WriteString("\n")30 b.WriteString("\n")
32 31
33 for i, option := range rm.AUROptions() {32 for i, option := range rm.AUROptions() {
34- prefix := " "33+ flatIdx := i
35- style := lipgloss.NewStyle()34+ isCursor := rm.CursorIndex() == flatIdx
36- labelStyle := lipgloss.NewStyle()35+ isSelected := rm.AURSelectedIndex() == i
37-36+
38- if rm.FocusSection() == 0 && i == rm.AURSelectedIndex() {37+ // Selection marker
39- prefix = "> "38+ if isSelected {
40- style = active39+ b.WriteString(selectedMark.Render(" [x] "))
41- labelStyle = active40+ } else {
41+ b.WriteString(normalMark.Render(" [ ] "))
42 }42 }
43 43
44- b.WriteString(style.Render(prefix))44+ // Label
45- b.WriteString(labelStyle.Render(option.Label))45+ label := option.Label
46+ if isCursor {
47+ b.WriteString(cursorStyle.Render("> " + label))
48+ } else {
49+ b.WriteString(label)
50+ }
46 b.WriteString("\n")51 b.WriteString("\n")
47- b.WriteString(desc.Render(" " + option.Description))52+ b.WriteString(desc.Render(" " + option.Description))
48 b.WriteString("\n")53 b.WriteString("\n")
49 }54 }
50 55
51 b.WriteString("\n")56 b.WriteString("\n")
52 57
53 // Chaotic-AUR section58 // Chaotic-AUR section
54- chaoticHeaderStyle := section59+ b.WriteString(section.Render("Chaotic-AUR"))
55- if rm.FocusSection() == 1 {
56- chaoticHeaderStyle = activeSection
57- }
58- b.WriteString(chaoticHeaderStyle.Render("Chaotic-AUR"))
59 b.WriteString("\n")60 b.WriteString("\n")
60 61
62+ aurLen := len(rm.AUROptions())
61 for i, option := range rm.ChaoticOptions() {63 for i, option := range rm.ChaoticOptions() {
62- prefix := " "64+ flatIdx := aurLen + i
63- style := lipgloss.NewStyle()65+ isCursor := rm.CursorIndex() == flatIdx
64- labelStyle := lipgloss.NewStyle()66+ isSelected := rm.ChaoticSelectedIndex() == i
65-67+
66- if rm.FocusSection() == 1 && i == rm.ChaoticSelectedIndex() {68+ // Selection marker
67- prefix = "> "69+ if isSelected {
68- style = active70+ b.WriteString(selectedMark.Render(" [x] "))
69- labelStyle = active71+ } else {
72+ b.WriteString(normalMark.Render(" [ ] "))
70 }73 }
71 74
72- b.WriteString(style.Render(prefix))75+ // Label
73- b.WriteString(labelStyle.Render(option.Label))76+ label := option.Label
77+ if isCursor {
78+ b.WriteString(cursorStyle.Render("> " + label))
79+ } else {
80+ b.WriteString(label)
81+ }
74 b.WriteString("\n")82 b.WriteString("\n")
75- b.WriteString(desc.Render(" " + option.Description))83+ b.WriteString(desc.Render(" " + option.Description))
76 b.WriteString("\n")84 b.WriteString("\n")
77 }85 }
78 86
79 b.WriteString("\n")87 b.WriteString("\n")
80- b.WriteString(info.Render("↑/↓ to select • tab to switch section • enter to confirm • esc to go back"))88+ b.WriteString(info.Render("↑/↓ move • ←/→ or space select • enter confirm • esc back"))
81 89
82 return b.String()90 return b.String()
83 }91 }
A internal/ui/assets/arch-logo.png +0 -0 binary file: no text diff

binary file: no text diff

M internal/ui/assets/assets.go +3 -0
@@ -4,3 +4,6 @@ import _ "embed"
44
55 //go:embed logo.txt
66 var LogoASCII string
7+
8+//go:embed arch-logo.png
9+var LimineLogo []byte
@@ -4,3 +4,6 @@ import _ "embed"
4 4
5 //go:embed logo.txt5 //go:embed logo.txt
6 var LogoASCII string6 var LogoASCII string
7+
8+//go:embed arch-logo.png
9+var LimineLogo []byte
M test/vm/lib/qemu-profile.sh +3 -1
@@ -100,6 +100,8 @@ build_qemu_args() {
100100 QEMU_ARGS+=(-device hda-duplex,audiodev=audio0)
101101
102102 # ── GPU / Display ─────────────────────────────────────────────────────────────
103- QEMU_ARGS+=(-device "virtio-vga,xres=1920,yres=1080")
103+ # Use std VGA: works regardless of guest GPU drivers (virtio-vga needs
104+ # virtio-gpu module in the guest initramfs which may not be present).
105+ QEMU_ARGS+=(-device "VGA,vgamem_mb=64")
104106 QEMU_ARGS+=(-display "gtk,zoom-to-fit=on")
105107 }
@@ -100,6 +100,8 @@ build_qemu_args() {
100 QEMU_ARGS+=(-device hda-duplex,audiodev=audio0)100 QEMU_ARGS+=(-device hda-duplex,audiodev=audio0)
101 101
102 # ── GPU / Display ─────────────────────────────────────────────────────────────102 # ── GPU / Display ─────────────────────────────────────────────────────────────
103- QEMU_ARGS+=(-device "virtio-vga,xres=1920,yres=1080")103+ # Use std VGA: works regardless of guest GPU drivers (virtio-vga needs
104+ # virtio-gpu module in the guest initramfs which may not be present).
105+ QEMU_ARGS+=(-device "VGA,vgamem_mb=64")
104 QEMU_ARGS+=(-display "gtk,zoom-to-fit=on")106 QEMU_ARGS+=(-display "gtk,zoom-to-fit=on")
105 }107 }

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