detached bea76ec

feat(tui): add kernel selection screen

commit
bea76ece00fbd6d4bcbfa02f57afa7ac00e44c54
author
brice · · 2026-01-26 18:18 UTC
parent
c6c0266
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +202 -2
M internal/interfaces/tui/app.go +36 -2
@@ -30,6 +30,7 @@ type App struct {
3030 // UI models (using concrete types for compatibility with views/handlers)
3131 formModel *models.FormModelImpl
3232 diskModel *models.DiskModelImpl
33+ kernelModel *models.KernelModelImpl
3334 amdPstateModel *models.AMDPStateModelImpl
3435 gpuModel *models.GPUModelImpl
3536 installationModel *models.InstallationModelImpl
@@ -53,6 +54,7 @@ type Screen string
5354 const (
5455 ScreenForm Screen = "form"
5556 ScreenDisk Screen = "disk"
57+ ScreenKernel Screen = "kernel"
5658 ScreenAMDPState Screen = "amd-pstate"
5759 ScreenGPU Screen = "gpu"
5860 ScreenInstalling Screen = "installing"
@@ -77,6 +79,7 @@ func NewApp(
7779 logger: logger,
7880 formModel: models.NewFormModel(),
7981 diskModel: models.NewDiskModel(),
82+ kernelModel: models.NewKernelModel(),
8083 amdPstateModel: models.NewAMDPStateModel(),
8184 gpuModel: models.NewGPUModel(),
8285 installationModel: models.NewInstallationModel(),
@@ -149,6 +152,8 @@ func (a *App) View() string {
149152 return views.RenderForm(a.formModel)
150153 case ScreenDisk:
151154 return views.RenderDiskSelection(a.diskModel)
155+ case ScreenKernel:
156+ return views.RenderKernelSelection(a.kernelModel)
152157 case ScreenAMDPState:
153158 return views.RenderAMDPStateSelection(a.amdPstateModel)
154159 case ScreenGPU:
@@ -171,6 +176,8 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
171176 return a.handleFormInput(msg)
172177 case ScreenDisk:
173178 return a.handleDiskInput(msg)
179+ case ScreenKernel:
180+ return a.handleKernelInput(msg)
174181 case ScreenAMDPState:
175182 return a.handleAMDPStateInput(msg)
176183 case ScreenGPU:
@@ -309,6 +316,27 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
309316 return a, nil
310317 }
311318
319+func (a *App) handleKernelInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
320+ switch msg.String() {
321+ case "ctrl+c":
322+ return a, tea.Quit
323+ case "esc", "backspace":
324+ return a.startDiskSelection()
325+ case "up", "shift+tab":
326+ a.kernelModel.MoveUp()
327+ return a, nil
328+ case "down", "tab":
329+ a.kernelModel.MoveDown()
330+ return a, nil
331+ case "enter":
332+ selected := a.kernelModel.SelectedOption()
333+ a.formData.KernelVariant = selected.Package
334+ return a.startAMDPStateSelection()
335+ }
336+
337+ return a, nil
338+}
339+
312340 func (a *App) handleCPUDetected(msg CPUDetectedMsg) (tea.Model, tea.Cmd) {
313341 if msg.Err != nil {
314342 a.logger.Warn("CPU detection reported error", "error", msg.Err)
@@ -330,7 +358,7 @@ func (a *App) handleAMDPStateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
330358 case "ctrl+c":
331359 return a, tea.Quit
332360 case "esc", "backspace":
333- return a.startDiskSelection()
361+ return a.startKernelSelection()
334362 case "up", "shift+tab":
335363 a.amdPstateModel.MoveUp()
336364 return a, nil
@@ -408,6 +436,12 @@ func (a *App) startAMDPStateSelection() (tea.Model, tea.Cmd) {
408436 return a, a.detectCPUCmd()
409437 }
410438
439+func (a *App) startKernelSelection() (tea.Model, tea.Cmd) {
440+ a.currentScreen = ScreenKernel
441+ a.kernelModel.SetSelectedPackage(a.formData.KernelVariant)
442+ return a, nil
443+}
444+
411445 func (a *App) detectCPUCmd() tea.Cmd {
412446 return func() tea.Msg {
413447 info, err := legacysystem.DetectCPUInfo()
@@ -472,7 +506,7 @@ func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
472506 }
473507 // Update stored form data directly
474508 a.formData.TargetDisk = selected.Path
475- return a.startAMDPStateSelection()
509+ return a.startKernelSelection()
476510 }
477511 return a, nil
478512 }
@@ -30,6 +30,7 @@ type App struct {
30 // UI models (using concrete types for compatibility with views/handlers)30 // UI models (using concrete types for compatibility with views/handlers)
31 formModel *models.FormModelImpl31 formModel *models.FormModelImpl
32 diskModel *models.DiskModelImpl32 diskModel *models.DiskModelImpl
33+ kernelModel *models.KernelModelImpl
33 amdPstateModel *models.AMDPStateModelImpl34 amdPstateModel *models.AMDPStateModelImpl
34 gpuModel *models.GPUModelImpl35 gpuModel *models.GPUModelImpl
35 installationModel *models.InstallationModelImpl36 installationModel *models.InstallationModelImpl
@@ -53,6 +54,7 @@ type Screen string
53 const (54 const (
54 ScreenForm Screen = "form"55 ScreenForm Screen = "form"
55 ScreenDisk Screen = "disk"56 ScreenDisk Screen = "disk"
57+ ScreenKernel Screen = "kernel"
56 ScreenAMDPState Screen = "amd-pstate"58 ScreenAMDPState Screen = "amd-pstate"
57 ScreenGPU Screen = "gpu"59 ScreenGPU Screen = "gpu"
58 ScreenInstalling Screen = "installing"60 ScreenInstalling Screen = "installing"
@@ -77,6 +79,7 @@ func NewApp(
77 logger: logger,79 logger: logger,
78 formModel: models.NewFormModel(),80 formModel: models.NewFormModel(),
79 diskModel: models.NewDiskModel(),81 diskModel: models.NewDiskModel(),
82+ kernelModel: models.NewKernelModel(),
80 amdPstateModel: models.NewAMDPStateModel(),83 amdPstateModel: models.NewAMDPStateModel(),
81 gpuModel: models.NewGPUModel(),84 gpuModel: models.NewGPUModel(),
82 installationModel: models.NewInstallationModel(),85 installationModel: models.NewInstallationModel(),
@@ -149,6 +152,8 @@ func (a *App) View() string {
149 return views.RenderForm(a.formModel)152 return views.RenderForm(a.formModel)
150 case ScreenDisk:153 case ScreenDisk:
151 return views.RenderDiskSelection(a.diskModel)154 return views.RenderDiskSelection(a.diskModel)
155+ case ScreenKernel:
156+ return views.RenderKernelSelection(a.kernelModel)
152 case ScreenAMDPState:157 case ScreenAMDPState:
153 return views.RenderAMDPStateSelection(a.amdPstateModel)158 return views.RenderAMDPStateSelection(a.amdPstateModel)
154 case ScreenGPU:159 case ScreenGPU:
@@ -171,6 +176,8 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
171 return a.handleFormInput(msg)176 return a.handleFormInput(msg)
172 case ScreenDisk:177 case ScreenDisk:
173 return a.handleDiskInput(msg)178 return a.handleDiskInput(msg)
179+ case ScreenKernel:
180+ return a.handleKernelInput(msg)
174 case ScreenAMDPState:181 case ScreenAMDPState:
175 return a.handleAMDPStateInput(msg)182 return a.handleAMDPStateInput(msg)
176 case ScreenGPU:183 case ScreenGPU:
@@ -309,6 +316,27 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
309 return a, nil316 return a, nil
310 }317 }
311 318
319+func (a *App) handleKernelInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
320+ switch msg.String() {
321+ case "ctrl+c":
322+ return a, tea.Quit
323+ case "esc", "backspace":
324+ return a.startDiskSelection()
325+ case "up", "shift+tab":
326+ a.kernelModel.MoveUp()
327+ return a, nil
328+ case "down", "tab":
329+ a.kernelModel.MoveDown()
330+ return a, nil
331+ case "enter":
332+ selected := a.kernelModel.SelectedOption()
333+ a.formData.KernelVariant = selected.Package
334+ return a.startAMDPStateSelection()
335+ }
336+
337+ return a, nil
338+}
339+
312 func (a *App) handleCPUDetected(msg CPUDetectedMsg) (tea.Model, tea.Cmd) {340 func (a *App) handleCPUDetected(msg CPUDetectedMsg) (tea.Model, tea.Cmd) {
313 if msg.Err != nil {341 if msg.Err != nil {
314 a.logger.Warn("CPU detection reported error", "error", msg.Err)342 a.logger.Warn("CPU detection reported error", "error", msg.Err)
@@ -330,7 +358,7 @@ func (a *App) handleAMDPStateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
330 case "ctrl+c":358 case "ctrl+c":
331 return a, tea.Quit359 return a, tea.Quit
332 case "esc", "backspace":360 case "esc", "backspace":
333- return a.startDiskSelection()361+ return a.startKernelSelection()
334 case "up", "shift+tab":362 case "up", "shift+tab":
335 a.amdPstateModel.MoveUp()363 a.amdPstateModel.MoveUp()
336 return a, nil364 return a, nil
@@ -408,6 +436,12 @@ func (a *App) startAMDPStateSelection() (tea.Model, tea.Cmd) {
408 return a, a.detectCPUCmd()436 return a, a.detectCPUCmd()
409 }437 }
410 438
439+func (a *App) startKernelSelection() (tea.Model, tea.Cmd) {
440+ a.currentScreen = ScreenKernel
441+ a.kernelModel.SetSelectedPackage(a.formData.KernelVariant)
442+ return a, nil
443+}
444+
411 func (a *App) detectCPUCmd() tea.Cmd {445 func (a *App) detectCPUCmd() tea.Cmd {
412 return func() tea.Msg {446 return func() tea.Msg {
413 info, err := legacysystem.DetectCPUInfo()447 info, err := legacysystem.DetectCPUInfo()
@@ -472,7 +506,7 @@ func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
472 }506 }
473 // Update stored form data directly507 // Update stored form data directly
474 a.formData.TargetDisk = selected.Path508 a.formData.TargetDisk = selected.Path
475- return a.startAMDPStateSelection()509+ return a.startKernelSelection()
476 }510 }
477 return a, nil511 return a, nil
478 }512 }
A internal/interfaces/tui/models/kernel.go +116 -0
@@ -0,0 +1,116 @@
1+package models
2+
3+import "github.com/bnema/archup/internal/domain/packages"
4+
5+// KernelOption represents a selectable kernel option.
6+type KernelOption struct {
7+ Variant packages.KernelVariant
8+ Package string
9+ Description string
10+ Recommended bool
11+}
12+
13+// KernelModelImpl holds kernel selection state.
14+type KernelModelImpl struct {
15+ options []KernelOption
16+ selected int
17+}
18+
19+// NewKernelModel creates a new kernel selection model.
20+func NewKernelModel() *KernelModelImpl {
21+ options := []KernelOption{
22+ {
23+ Variant: packages.KernelStable,
24+ Package: packages.KernelStable.String(),
25+ Description: "Stable mainline kernel",
26+ Recommended: true,
27+ },
28+ {
29+ Variant: packages.KernelLTS,
30+ Package: packages.KernelLTS.String(),
31+ Description: "Long-term support (maximum stability)",
32+ },
33+ {
34+ Variant: packages.KernelZen,
35+ Package: packages.KernelZen.String(),
36+ Description: "Performance-optimized for general use",
37+ },
38+ {
39+ Variant: packages.KernelHardened,
40+ Package: packages.KernelHardened.String(),
41+ Description: "Security-focused kernel",
42+ },
43+ {
44+ Variant: packages.KernelCachyOS,
45+ Package: packages.KernelCachyOS.String(),
46+ Description: "Gaming-optimized",
47+ },
48+ }
49+
50+ selected := 0
51+ for i, option := range options {
52+ if option.Recommended {
53+ selected = i
54+ break
55+ }
56+ }
57+
58+ return &KernelModelImpl{
59+ options: options,
60+ selected: selected,
61+ }
62+}
63+
64+// Options returns the selectable kernel options.
65+func (km *KernelModelImpl) Options() []KernelOption {
66+ return km.options
67+}
68+
69+// SelectedIndex returns the current selection index.
70+func (km *KernelModelImpl) SelectedIndex() int {
71+ return km.selected
72+}
73+
74+// SelectedOption returns the currently selected kernel option.
75+func (km *KernelModelImpl) SelectedOption() KernelOption {
76+ if len(km.options) == 0 {
77+ return KernelOption{}
78+ }
79+ if km.selected < 0 || km.selected >= len(km.options) {
80+ return km.options[0]
81+ }
82+ return km.options[km.selected]
83+}
84+
85+// MoveUp moves selection up.
86+func (km *KernelModelImpl) MoveUp() {
87+ if len(km.options) == 0 {
88+ return
89+ }
90+ if km.selected == 0 {
91+ km.selected = len(km.options) - 1
92+ return
93+ }
94+ km.selected--
95+}
96+
97+// MoveDown moves selection down.
98+func (km *KernelModelImpl) MoveDown() {
99+ if len(km.options) == 0 {
100+ return
101+ }
102+ km.selected = (km.selected + 1) % len(km.options)
103+}
104+
105+// SetSelectedPackage selects a kernel by package name.
106+func (km *KernelModelImpl) SetSelectedPackage(pkg string) {
107+ if pkg == "" {
108+ return
109+ }
110+ for i, option := range km.options {
111+ if option.Package == pkg {
112+ km.selected = i
113+ return
114+ }
115+ }
116+}
@@ -0,0 +1,116 @@
1+package models
2+
3+import "github.com/bnema/archup/internal/domain/packages"
4+
5+// KernelOption represents a selectable kernel option.
6+type KernelOption struct {
7+ Variant packages.KernelVariant
8+ Package string
9+ Description string
10+ Recommended bool
11+}
12+
13+// KernelModelImpl holds kernel selection state.
14+type KernelModelImpl struct {
15+ options []KernelOption
16+ selected int
17+}
18+
19+// NewKernelModel creates a new kernel selection model.
20+func NewKernelModel() *KernelModelImpl {
21+ options := []KernelOption{
22+ {
23+ Variant: packages.KernelStable,
24+ Package: packages.KernelStable.String(),
25+ Description: "Stable mainline kernel",
26+ Recommended: true,
27+ },
28+ {
29+ Variant: packages.KernelLTS,
30+ Package: packages.KernelLTS.String(),
31+ Description: "Long-term support (maximum stability)",
32+ },
33+ {
34+ Variant: packages.KernelZen,
35+ Package: packages.KernelZen.String(),
36+ Description: "Performance-optimized for general use",
37+ },
38+ {
39+ Variant: packages.KernelHardened,
40+ Package: packages.KernelHardened.String(),
41+ Description: "Security-focused kernel",
42+ },
43+ {
44+ Variant: packages.KernelCachyOS,
45+ Package: packages.KernelCachyOS.String(),
46+ Description: "Gaming-optimized",
47+ },
48+ }
49+
50+ selected := 0
51+ for i, option := range options {
52+ if option.Recommended {
53+ selected = i
54+ break
55+ }
56+ }
57+
58+ return &KernelModelImpl{
59+ options: options,
60+ selected: selected,
61+ }
62+}
63+
64+// Options returns the selectable kernel options.
65+func (km *KernelModelImpl) Options() []KernelOption {
66+ return km.options
67+}
68+
69+// SelectedIndex returns the current selection index.
70+func (km *KernelModelImpl) SelectedIndex() int {
71+ return km.selected
72+}
73+
74+// SelectedOption returns the currently selected kernel option.
75+func (km *KernelModelImpl) SelectedOption() KernelOption {
76+ if len(km.options) == 0 {
77+ return KernelOption{}
78+ }
79+ if km.selected < 0 || km.selected >= len(km.options) {
80+ return km.options[0]
81+ }
82+ return km.options[km.selected]
83+}
84+
85+// MoveUp moves selection up.
86+func (km *KernelModelImpl) MoveUp() {
87+ if len(km.options) == 0 {
88+ return
89+ }
90+ if km.selected == 0 {
91+ km.selected = len(km.options) - 1
92+ return
93+ }
94+ km.selected--
95+}
96+
97+// MoveDown moves selection down.
98+func (km *KernelModelImpl) MoveDown() {
99+ if len(km.options) == 0 {
100+ return
101+ }
102+ km.selected = (km.selected + 1) % len(km.options)
103+}
104+
105+// SetSelectedPackage selects a kernel by package name.
106+func (km *KernelModelImpl) SetSelectedPackage(pkg string) {
107+ if pkg == "" {
108+ return
109+ }
110+ for i, option := range km.options {
111+ if option.Package == pkg {
112+ km.selected = i
113+ return
114+ }
115+ }
116+}
A internal/interfaces/tui/views/kernel.go +50 -0
@@ -0,0 +1,50 @@
1+package views
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/interfaces/tui/models"
7+ "github.com/charmbracelet/lipgloss"
8+)
9+
10+// RenderKernelSelection renders the kernel selection screen.
11+func RenderKernelSelection(km *models.KernelModelImpl) string {
12+ var b strings.Builder
13+
14+ title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15+ info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16+ active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17+
18+ b.WriteString("\n")
19+ b.WriteString(title.Render("Kernel Selection"))
20+ b.WriteString("\n\n")
21+
22+ if km == nil || len(km.Options()) == 0 {
23+ b.WriteString(info.Render("No kernel options available."))
24+ return b.String()
25+ }
26+
27+ for i, option := range km.Options() {
28+ prefix := " "
29+ style := lipgloss.NewStyle()
30+ label := option.Package
31+ if option.Recommended {
32+ label = label + " (recommended)"
33+ }
34+ if i == km.SelectedIndex() {
35+ prefix = "> "
36+ style = active
37+ }
38+ b.WriteString(style.Render(prefix + label))
39+ if option.Description != "" {
40+ b.WriteString("\n")
41+ b.WriteString(info.Render(" " + option.Description))
42+ }
43+ b.WriteString("\n")
44+ }
45+
46+ b.WriteString("\n")
47+ b.WriteString(info.Render("Up/Down Move | Enter Confirm | Esc Back | Ctrl+C Quit"))
48+
49+ return b.String()
50+}
@@ -0,0 +1,50 @@
1+package views
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/interfaces/tui/models"
7+ "github.com/charmbracelet/lipgloss"
8+)
9+
10+// RenderKernelSelection renders the kernel selection screen.
11+func RenderKernelSelection(km *models.KernelModelImpl) string {
12+ var b strings.Builder
13+
14+ title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15+ info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16+ active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17+
18+ b.WriteString("\n")
19+ b.WriteString(title.Render("Kernel Selection"))
20+ b.WriteString("\n\n")
21+
22+ if km == nil || len(km.Options()) == 0 {
23+ b.WriteString(info.Render("No kernel options available."))
24+ return b.String()
25+ }
26+
27+ for i, option := range km.Options() {
28+ prefix := " "
29+ style := lipgloss.NewStyle()
30+ label := option.Package
31+ if option.Recommended {
32+ label = label + " (recommended)"
33+ }
34+ if i == km.SelectedIndex() {
35+ prefix = "> "
36+ style = active
37+ }
38+ b.WriteString(style.Render(prefix + label))
39+ if option.Description != "" {
40+ b.WriteString("\n")
41+ b.WriteString(info.Render(" " + option.Description))
42+ }
43+ b.WriteString("\n")
44+ }
45+
46+ b.WriteString("\n")
47+ b.WriteString(info.Render("Up/Down Move | Enter Confirm | Esc Back | Ctrl+C Quit"))
48+
49+ return b.String()
50+}

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