Commit a0ad4f8

Ansari <ping@ansari.wtf>
2026-03-02 23:58:19
Actions in rules sections
1 parent f72996b
Changed files (4)
internal
internal/sections/rules/update.go
@@ -10,53 +10,504 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 )
 
-func (m Model) Update(msg tea.Msg, rules []ufw.Rule) (Model, tea.Cmd) {
+const (
+	ActionToggle   = "toggle"
+	ActionMoveUp   = "move_up"
+	ActionMoveDown = "move_down"
+	ActionAdd      = "add"
+)
+
+func (m Model) Update(msg tea.Msg, data RulesData) (Model, tea.Cmd) {
+	rules := m.getActiveRules(data)
+
+	if m.addWizard != nil {
+		return m.updateAddWizard(msg)
+	}
+
+	if m.showDeleteConfirm {
+		switch msg := msg.(type) {
+		case tea.KeyMsg:
+			switch msg.String() {
+			case "y", "Y":
+				if m.deleteRule != nil {
+					log.Printf("Deleting rule #%d (confirmed)", m.deleteRule.Num)
+					ufw.DeleteRule(m.deleteRule.Num)
+				}
+				m.showDeleteConfirm = false
+				m.deleteRule = nil
+				return m, keys.Refresh()
+			case "n", "N", "esc":
+				m.showDeleteConfirm = false
+				m.deleteRule = nil
+				return m, nil
+			}
+		}
+		return m, nil
+	}
+
+	if m.showDetails {
+		switch msg.(type) {
+		case tea.KeyMsg:
+			m.showDetails = false
+			m.detailRule = nil
+			return m, nil
+		}
+		return m, nil
+	}
+
 	if m.menu != nil {
 		if quit := m.menu.Update(msg); quit {
+			if m.menuContext != nil && m.menuContext.PendingSubmenu {
+				return m.handlePendingSubmenu()
+			}
 			m.menu = nil
+			m.menuContext = nil
 			return m, keys.Refresh()
 		}
-	} else {
-		switch msg := msg.(type) {
-		case tea.KeyMsg:
+		return m, nil
+	}
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, keys.Bindings.SwitchTable):
+			if m.activeTable == IPv4Table {
+				m.activeTable = IPv6Table
+			} else {
+				m.activeTable = IPv4Table
+			}
+			return m, nil
+
+		case key.Matches(msg, keys.Bindings.CursorUp):
+			m.moveCursorUp()
+			return m, nil
+
+		case key.Matches(msg, keys.Bindings.CursorDown):
+			m.moveCursorDown(rules)
+			return m, nil
+
+		case key.Matches(msg, keys.Bindings.Execute):
+			if len(rules) > 0 {
+				return m.openMainMenu(data)
+			}
+			m.addWizard = NewAddWizard()
+			return m, nil
+
+		case key.Matches(msg, keys.Bindings.AddRule):
+			m.addWizard = NewAddWizard()
+			return m, nil
+
+		case key.Matches(msg, keys.Bindings.Info):
+			cursorLine := m.getCurrentCursor()
+			if len(rules) > 0 && cursorLine < len(rules) {
+				ruleCopy := rules[cursorLine]
+				m.showDetails = true
+				m.detailRule = &ruleCopy
+				return m, nil
+			}
+
+		case key.Matches(msg, keys.Bindings.Delete):
+			cursorLine := m.getCurrentCursor()
+			if len(rules) > 0 && cursorLine < len(rules) {
+				ruleCopy := rules[cursorLine]
+				m.showDeleteConfirm = true
+				m.deleteRule = &ruleCopy
+				return m, nil
+			}
+		}
+	}
+	return m, nil
+}
+
+func (m Model) updateAddWizard(msg tea.Msg) (Model, tea.Cmd) {
+	w := m.addWizard
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, keys.Bindings.Quit):
+			if w.InputMode {
+				w.InputMode = false
+				w.Input = ""
+				return m, nil
+			}
+			m.addWizard = nil
+			return m, nil
+		}
+
+		if w.InputMode {
 			switch {
-			case key.Matches(msg, keys.Bindings.CursorUp):
-				if m.cursorLine > 0 {
-					m.cursorLine--
+			case key.Matches(msg, keys.Bindings.Execute):
+				return m.confirmWizardInput()
+			case key.Matches(msg, keys.Bindings.Back):
+				if len(w.Input) > 0 {
+					w.Input = w.Input[:len(w.Input)-1]
 				}
-			case key.Matches(msg, keys.Bindings.CursorDown):
-				if m.cursorLine < len(rules)-1 {
-					m.cursorLine++
+			default:
+				if len(msg.String()) == 1 {
+					w.Input += msg.String()
 				}
-			case key.Matches(msg, keys.Bindings.Execute):
-				if len(rules) > 0 {
-					return m.openMenu(rules)
+			}
+			return m, nil
+		}
+
+		switch {
+		case key.Matches(msg, keys.Bindings.CursorUp):
+			if w.Cursor > 0 {
+				w.Cursor--
+			}
+		case key.Matches(msg, keys.Bindings.CursorDown):
+			if w.Cursor < len(w.Options)-1 {
+				w.Cursor++
+			}
+		case key.Matches(msg, keys.Bindings.Execute):
+			return m.selectWizardOption()
+		case key.Matches(msg, keys.Bindings.CustomInput):
+			if w.Step == StepPort || w.Step == StepSource || w.Step == StepDestination {
+				w.InputMode = true
+				w.Input = ""
+			}
+		case key.Matches(msg, keys.Bindings.Back):
+			return m.wizardPrevStep()
+		}
+	}
+
+	return m, nil
+}
+
+func (m Model) selectWizardOption() (Model, tea.Cmd) {
+	w := m.addWizard
+	if w.Cursor >= len(w.Options) {
+		return m, nil
+	}
+
+	selected := w.Options[w.Cursor]
+
+	switch w.Step {
+	case StepAction:
+		w.Params.Action = selected
+		w.Step = StepDirection
+		w.Options = append([]string{"Both (in & out)"}, ufw.Directions...)
+		w.Cursor = 0
+
+	case StepDirection:
+		if selected == "Both (in & out)" {
+			w.Params.Direction = ""
+		} else {
+			w.Params.Direction = selected
+		}
+		w.Step = StepProtocol
+		w.Options = ufw.Protocols
+		w.Cursor = 0
+
+	case StepProtocol:
+		w.Params.Protocol = selected
+		w.Step = StepPort
+		w.Options = []string{"Any (no port filter)"}
+		for _, p := range ufw.CommonPorts {
+			w.Options = append(w.Options, p.Port+" ("+p.Name+")")
+		}
+		w.Cursor = 0
+
+	case StepPort:
+		if selected == "Any (no port filter)" {
+			w.Params.Port = ""
+		} else {
+			for i, c := range selected {
+				if c == ' ' {
+					w.Params.Port = selected[:i]
+					break
 				}
 			}
 		}
+		w.Step = StepSource
+		w.Options = []string{"Any", "Custom..."}
+		w.Cursor = 0
+
+	case StepSource:
+		switch selected {
+		case "Any":
+			w.Params.FromAddr = ""
+		case "Custom...":
+			w.InputMode = true
+			w.Input = ""
+			return m, nil
+		}
+		w.Step = StepDestination
+		w.Options = []string{"Any", "Custom..."}
+		w.Cursor = 0
+
+	case StepDestination:
+		switch selected {
+		case "Any":
+			w.Params.ToAddr = ""
+		case "Custom...":
+			w.InputMode = true
+			w.Input = ""
+			return m, nil
+		}
+		w.Step = StepInterface
+		w.Options = append([]string{"All interfaces"}, w.Interfaces...)
+		w.Cursor = 0
+
+	case StepInterface:
+		if selected == "All interfaces" {
+			w.Params.Interface = ""
+		} else {
+			w.Params.Interface = selected
+		}
+		w.Step = StepConfirm
+		w.Options = []string{"Confirm and Add Rule", "Cancel"}
+		w.Cursor = 0
+
+	case StepConfirm:
+		if selected == "Confirm and Add Rule" {
+			// Execute the command
+			log.Printf("Adding rule: %+v", w.Params)
+			_, stderr, err := ufw.AddNewRule(w.Params)
+			if err != nil {
+				log.Printf("Error adding rule: %s", stderr)
+				w.Error = stderr
+				return m, nil
+			}
+			m.addWizard = nil
+			return m, keys.Refresh()
+		} else {
+			// Cancel
+			m.addWizard = nil
+			return m, nil
+		}
 	}
+
 	return m, nil
 }
 
-func (m Model) openMenu(rules []ufw.Rule) (Model, tea.Cmd) {
-	if len(rules) == 0 || m.cursorLine >= len(rules) {
+func (m Model) confirmWizardInput() (Model, tea.Cmd) {
+	w := m.addWizard
+	input := w.Input
+	w.InputMode = false
+	w.Input = ""
+
+	switch w.Step {
+	case StepPort:
+		w.Params.Port = input
+		w.Step = StepSource
+		w.Options = []string{"Any", "Custom..."}
+		w.Cursor = 0
+
+	case StepSource:
+		w.Params.FromAddr = input
+		w.Step = StepDestination
+		w.Options = []string{"Any", "Custom..."}
+		w.Cursor = 0
+
+	case StepDestination:
+		w.Params.ToAddr = input
+		w.Step = StepInterface
+		w.Options = append([]string{"All interfaces"}, w.Interfaces...)
+		w.Cursor = 0
+	}
+
+	return m, nil
+}
+
+func (m Model) wizardPrevStep() (Model, tea.Cmd) {
+	w := m.addWizard
+
+	switch w.Step {
+	case StepAction:
+		m.addWizard = nil
+		return m, nil
+
+	case StepDirection:
+		w.Step = StepAction
+		w.Options = ufw.Actions
+		w.Cursor = 0
+
+	case StepProtocol:
+		w.Step = StepDirection
+		w.Options = append([]string{"Both (in & out)"}, ufw.Directions...)
+		w.Cursor = 0
+
+	case StepPort:
+		w.Step = StepProtocol
+		w.Options = ufw.Protocols
+		w.Cursor = 0
+
+	case StepSource:
+		w.Step = StepPort
+		w.Options = []string{"Any (no port filter)"}
+		for _, p := range ufw.CommonPorts {
+			w.Options = append(w.Options, p.Port+" ("+p.Name+")")
+		}
+		w.Cursor = 0
+
+	case StepDestination:
+		w.Step = StepSource
+		w.Options = []string{"Any", "Custom..."}
+		w.Cursor = 0
+
+	case StepInterface:
+		w.Step = StepDestination
+		w.Options = []string{"Any", "Custom..."}
+		w.Cursor = 0
+
+	case StepConfirm:
+		w.Step = StepInterface
+		w.Options = append([]string{"All interfaces"}, w.Interfaces...)
+		w.Cursor = 0
+	}
+
+	return m, nil
+}
+
+func (m Model) getActiveRules(data RulesData) []ufw.Rule {
+	if m.activeTable == IPv6Table {
+		return data.IPv6
+	}
+	return data.IPv4
+}
+
+func (m Model) getCurrentCursor() int {
+	if m.activeTable == IPv6Table {
+		return m.ipv6CursorLine
+	}
+	return m.ipv4CursorLine
+}
+
+func (m *Model) moveCursorUp() {
+	if m.activeTable == IPv6Table {
+		if m.ipv6CursorLine > 0 {
+			m.ipv6CursorLine--
+		}
+	} else {
+		if m.ipv4CursorLine > 0 {
+			m.ipv4CursorLine--
+		}
+	}
+}
+
+func (m *Model) moveCursorDown(rules []ufw.Rule) {
+	if m.activeTable == IPv6Table {
+		if m.ipv6CursorLine < len(rules)-1 {
+			m.ipv6CursorLine++
+		}
+	} else {
+		if m.ipv4CursorLine < len(rules)-1 {
+			m.ipv4CursorLine++
+		}
+	}
+}
+
+func (m Model) handlePendingSubmenu() (Model, tea.Cmd) {
+	if m.menuContext == nil {
+		m.menu = nil
+		return m, keys.Refresh()
+	}
+
+	log.Printf("handlePendingSubmenu: Action=%s, PendingSubmenu=%v", m.menuContext.Action, m.menuContext.PendingSubmenu)
+	m.menuContext.PendingSubmenu = false
+
+	switch m.menuContext.Action {
+	case ActionToggle:
+		return m.openToggleActionMenu()
+
+	case ActionMoveUp:
+		rule := m.menuContext.SelectedRule
+		if rule != nil {
+			log.Printf("Moving rule #%d up (IPv6=%v)", rule.Num, rule.IPv6)
+			err := ufw.MoveRule(*rule, -1, rule.Action)
+			if err != nil {
+				log.Printf("Error moving rule up: %v", err)
+			}
+		}
+		m.menu = nil
+		m.menuContext = nil
+		return m, keys.Refresh()
+
+	case ActionMoveDown:
+		rule := m.menuContext.SelectedRule
+		if rule != nil {
+			log.Printf("Moving rule #%d down (IPv6=%v)", rule.Num, rule.IPv6)
+			err := ufw.MoveRule(*rule, 1, rule.Action)
+			if err != nil {
+				log.Printf("Error moving rule down: %v", err)
+			}
+		}
+		m.menu = nil
+		m.menuContext = nil
+		return m, keys.Refresh()
+
+	case ActionAdd:
+		m.menu = nil
+		m.menuContext = nil
+		m.addWizard = NewAddWizard()
 		return m, nil
 	}
 
-	selectedRule := rules[m.cursorLine]
-	log.Println(selectedRule)
+	m.menu = nil
+	m.menuContext = nil
+	return m, keys.Refresh()
+}
+
+func (m Model) openMainMenu(data RulesData) (Model, tea.Cmd) {
+	rules := m.getActiveRules(data)
+	cursorLine := m.getCurrentCursor()
 
-	options := ui.MakeMenuItems(
-		[]string{"Delete Rule"},
-		func(label string) tea.Cmd {
-			switch label {
-			case "Delete Rule":
-				log.Printf("Deleting rule #%d", selectedRule.Num)
-				ufw.DeleteRule(selectedRule.Num)
+	if len(rules) == 0 || cursorLine >= len(rules) {
+		return m, nil
+	}
+
+	selectedRule := rules[cursorLine]
+	ruleCopy := selectedRule // Make a copy to store in context
+
+	menuLabels := []string{
+		"Toggle Action",
+		"Move Up",
+		"Move Down",
+		"Add New Rule",
+	}
+
+	m.menuContext = &MenuContext{
+		SelectedRule: &ruleCopy,
+		TotalRules:   len(rules),
+	}
+
+	options := ui.MakeMenuItems(menuLabels, func(label string) tea.Cmd {
+		switch label {
+		case "Toggle Action":
+			m.menuContext.Action = ActionToggle
+		case "Move Up":
+			m.menuContext.Action = ActionMoveUp
+		case "Move Down":
+			m.menuContext.Action = ActionMoveDown
+		case "Add New Rule":
+			m.menuContext.Action = ActionAdd
+		}
+		m.menuContext.PendingSubmenu = true
+		return nil
+	})
+
+	menu := ui.NewMenu(options, m.styles)
+	m.menu = &menu
+	return m, nil
+}
+
+func (m Model) openToggleActionMenu() (Model, tea.Cmd) {
+	rule := m.menuContext.SelectedRule
+
+	options := ui.MakeMenuItems(ufw.Actions, func(newAction string) tea.Cmd {
+		if rule != nil && newAction != rule.Action {
+			log.Printf("Toggling rule #%d from %s to %s (IPv6=%v)", rule.Num, rule.Action, newAction, rule.IPv6)
+
+			err := ufw.MoveRule(*rule, 0, newAction)
+			if err != nil {
+				log.Printf("Error toggling rule: %v", err)
 			}
-			return nil
-		},
-	)
+		}
+		m.menuContext = nil
+		return nil
+	})
 
 	menu := ui.NewMenu(options, m.styles)
 	m.menu = &menu
internal/sections/rules/view.go
@@ -10,9 +10,63 @@ import (
 	"github.com/charmbracelet/lipgloss"
 )
 
-func (m Model) View(rules []ufw.Rule) string {
+type RulesData struct {
+	IPv4 []ufw.Rule
+	IPv6 []ufw.Rule
+}
+
+func (m Model) View(data RulesData) string {
+	sectionActiveNoMenu := m.menu == nil && m.active && !m.showDetails && !m.showDeleteConfirm
+
+	ipTable := m.renderTable(data.IPv4, data.IPv6, m.ipv4CursorLine, sectionActiveNoMenu && m.activeTable == IPv4Table)
+	if m.activeTable == IPv6Table {
+		ipTable = m.renderTable(data.IPv4, data.IPv6, m.ipv6CursorLine, sectionActiveNoMenu && m.activeTable == IPv6Table)
+	}
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		ipTable,
+	)
+
+	return ui.TitledBox("Active Rules", content, m.styles, -1, m.active)
+}
+
+func (m Model) renderTable(ipV4Rules []ufw.Rule, ipV6Rules []ufw.Rule, cursorLine int, isActive bool) string {
 	var rows []string
 
+	titleStyle := m.styles.Label
+	if isActive {
+		titleStyle = titleStyle.Bold(true).Foreground(lipgloss.Color("12"))
+	}
+	activeTitle := "IPv4 Rules"
+	inActiveTitle := "IPv6 Rules"
+
+	rules := ipV4Rules
+
+	maxIPv4 := len(fmt.Sprintf("%s (%d)", "IPv4 Rules", 9999))
+	maxIPv6 := len(fmt.Sprintf("%s (%d)", "IPv6 Rules", 9999))
+
+	padTitle := func(title string, count int, width int) string {
+		return fmt.Sprintf("%-*s", width, fmt.Sprintf("%s (%d)", title, count))
+	}
+
+	tabTitle := titleStyle.Render(padTitle(activeTitle, len(ipV4Rules), maxIPv4)) + "|  " +
+		m.styles.Label.Foreground(lipgloss.Color("241")).Render(padTitle(inActiveTitle, len(ipV6Rules), maxIPv6))
+
+	if m.activeTable == IPv6Table {
+		rules = ipV6Rules
+		tabTitle = m.styles.Label.Foreground(lipgloss.Color("241")).Render(padTitle(activeTitle, len(ipV4Rules), maxIPv4)) + "|  " +
+			titleStyle.Render(padTitle(inActiveTitle, len(ipV6Rules), maxIPv6))
+	}
+
+	rows = append(rows, tabTitle)
+	rows = append(rows, "")
+
+	if len(rules) == 0 {
+		emptyMsg := m.styles.Label.Foreground(lipgloss.Color("241")).Render("  (no rules)")
+		rows = append(rows, emptyMsg)
+		return strings.Join(rows, "\n")
+	}
+
 	header := fmt.Sprintf(
 		"%-3s │ %-6s │ %-5s │ %-16s │ %-5s │ %-16s │ %-5s",
 		"#", "Action", "Proto", "Source", "sPort", "Destination", "dPort")
@@ -21,8 +75,6 @@ func (m Model) View(rules []ufw.Rule) string {
 	line := strings.Repeat("─", lipgloss.Width(headerContent))
 	rows = append(rows, "  "+headerContent, "  "+m.styles.Label.UnsetWidth().Render(line))
 
-	sectionActiveNoMenu := m.menu == nil && m.active && !m.showDetails && !m.showDeleteConfirm
-
 	for i, r := range rules {
 		action := fmt.Sprintf("%-6s", r.Action)
 		row := fmt.Sprintf(
@@ -30,22 +82,25 @@ func (m Model) View(rules []ufw.Rule) string {
 			r.Num,
 			action,
 			r.ToProtocol,
-			r.FromSource,
-			r.FromPort,
-			r.ToDest,
-			r.ToPort,
+			truncate(r.FromSource, 16),
+			truncate(r.FromPort, 5),
+			truncate(r.ToDest, 16),
+			truncate(r.ToPort, 5),
 		)
-		rows = append(rows, ui.InsertCursorRulesSection(row, m.cursorLine == i && sectionActiveNoMenu, m.styles, r.Action))
+		rows = append(rows, ui.InsertCursorRulesSection(row, cursorLine == i && isActive, m.styles, r.Action))
 	}
-	table := strings.Join(rows, "\n")
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		table,
-	)
-	return ui.TitledBox("Active Rules", content, m.styles, -1, m.active)
+
+	return strings.Join(rows, "\n")
+}
+
+func truncate(s string, maxLen int) string {
+	s = strings.TrimSuffix(s, " (v6)")
+	if len(s) > maxLen {
+		return s[:maxLen-1] + "…"
+	}
+	return s
 }
 
-// DetailView renders the rule detail overlay
 func (m Model) DetailView() string {
 	if m.detailRule == nil {
 		return ""
@@ -53,12 +108,18 @@ func (m Model) DetailView() string {
 
 	r := m.detailRule
 
-	// Build detail content
 	var lines []string
 	lines = append(lines, "")
 	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("Rule #:"), m.styles.Value.Render(fmt.Sprintf("%d", r.Num))))
 	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("Action:"), ui.GetPolicyStyle(m.styles, r.Action).Render(r.Action)))
 	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("Protocol:"), m.styles.Value.Render(r.ToProtocol)))
+
+	ipVersion := "IPv4"
+	if r.IPv6 {
+		ipVersion = "IPv6"
+	}
+	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("IP Version:"), m.styles.Value.Render(ipVersion)))
+
 	lines = append(lines, "")
 	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("Source:"), m.styles.Value.Render(r.FromSource)))
 	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("Source Port:"), m.styles.Value.Render(r.FromPort)))
@@ -136,3 +197,130 @@ func (m Model) DeleteConfirmView() string {
 
 	return ui.TitledBox("Confirm Delete", content, m.styles, -1, true)
 }
+
+func (m Model) AddWizardView() string {
+	w := m.addWizard
+	if w == nil {
+		return ""
+	}
+
+	var lines []string
+	lines = append(lines, "")
+
+	if w.Params.Action != "" {
+		lines = append(lines, fmt.Sprintf("  %s %s", m.styles.Label.Render("Action:"), ui.GetPolicyStyle(m.styles, w.Params.Action).Render(w.Params.Action)))
+	}
+	if w.Step > StepDirection && w.Params.Direction != "" {
+		lines = append(lines, fmt.Sprintf("  %s %s", m.styles.Label.Render("Direction:"), m.styles.Value.Render(w.Params.Direction)))
+	} else if w.Step > StepDirection {
+		lines = append(lines, fmt.Sprintf("  %s %s", m.styles.Label.Render("Direction:"), m.styles.Value.Render("both")))
+	}
+	if w.Step > StepProtocol {
+		lines = append(lines, fmt.Sprintf("  %s %s", m.styles.Label.Render("Protocol:"), m.styles.Value.Render(w.Params.Protocol)))
+	}
+	if w.Step > StepPort {
+		port := w.Params.Port
+		if port == "" {
+			port = "any"
+		}
+		lines = append(lines, fmt.Sprintf("  %s %s", m.styles.Label.Render("Port:"), m.styles.Value.Render(port)))
+	}
+	if w.Step > StepSource {
+		src := w.Params.FromAddr
+		if src == "" {
+			src = "any"
+		}
+		lines = append(lines, fmt.Sprintf("  %s %s", m.styles.Label.Render("Source:"), m.styles.Value.Render(src)))
+	}
+	if w.Step > StepDestination {
+		dst := w.Params.ToAddr
+		if dst == "" {
+			dst = "any"
+		}
+		lines = append(lines, fmt.Sprintf("  %s %s", m.styles.Label.Render("Destination:"), m.styles.Value.Render(dst)))
+	}
+	if w.Step > StepInterface {
+		iface := w.Params.Interface
+		if iface == "" {
+			iface = "all"
+		}
+		lines = append(lines, fmt.Sprintf("  %s %s", m.styles.Label.Render("Interface:"), m.styles.Value.Render(iface)))
+	}
+
+	if len(lines) > 1 {
+		lines = append(lines, "")
+	}
+
+	stepTitle := m.getWizardStepTitle(w.Step)
+	lines = append(lines, m.styles.Title.Render(fmt.Sprintf("  %s", stepTitle)))
+	lines = append(lines, "")
+
+	if w.Error != "" {
+		lines = append(lines, m.styles.Error.Render(fmt.Sprintf("  Error: %s", w.Error)))
+		lines = append(lines, "")
+	}
+
+	if w.InputMode {
+		inputBox := fmt.Sprintf("  > %s_", w.Input)
+		lines = append(lines, m.styles.Value.Render(inputBox))
+		lines = append(lines, "")
+		lines = append(lines, m.styles.Label.Foreground(lipgloss.Color("241")).Render("  [Enter] Confirm  [Esc] Cancel"))
+	} else {
+		for i, opt := range w.Options {
+			prefix := "  "
+			style := m.styles.Label
+			if i == w.Cursor {
+				prefix = "> "
+				style = m.styles.Value.Bold(true)
+			}
+			lines = append(lines, style.Render(fmt.Sprintf("  %s%s", prefix, opt)))
+		}
+		lines = append(lines, "")
+
+		hints := "[Enter] Select  [Esc] Cancel"
+		if w.Step > StepAction {
+			hints = "[b] Back  [Esc] Cancel"
+		}
+		if w.Step == StepPort || w.Step == StepSource || w.Step == StepDestination {
+			hints = "[c] Custom  [b] Back  [Esc] Cancel"
+		}
+		lines = append(lines, m.styles.Label.UnsetWidth().Foreground(lipgloss.Color("241")).Render(hints))
+	}
+
+	lines = append(lines, "")
+
+	// Show preview command at confirm step
+	// if w.Step == StepConfirm {
+	// 	args := ufw.BuildAddRuleCommand(w.Params)
+	// 	cmdPreview := "sudo ufw " + strings.Join(args, " ")
+	// 	lines = append(lines, m.styles.Label.Render("  Command:"))
+	// 	lines = append(lines, m.styles.Value.Render(fmt.Sprintf("  %s", cmdPreview)))
+	// 	lines = append(lines, "")
+	// }
+
+	content := strings.Join(lines, "\n")
+	return ui.TitledBox("Add New Rule", content, m.styles, 40, true)
+}
+
+func (m Model) getWizardStepTitle(step WizardStep) string {
+	switch step {
+	case StepAction:
+		return "Select Action"
+	case StepDirection:
+		return "Select Direction"
+	case StepProtocol:
+		return "Select Protocol"
+	case StepPort:
+		return "Select Port"
+	case StepSource:
+		return "Select Source Address"
+	case StepDestination:
+		return "Select Destination Address"
+	case StepInterface:
+		return "Select Interface"
+	case StepConfirm:
+		return "Confirm Rule"
+	default:
+		return "Add Rule"
+	}
+}
internal/ufw/rules.go
@@ -62,6 +62,8 @@ func parseRuleLine(num int, line string) Rule {
 		Raw: line,
 	}
 
+	rule.IPv6 = strings.Contains(line, "(v6)")
+
 	commentIdx := strings.Index(line, "#")
 	if commentIdx >= 0 {
 		rule.Comment = strings.TrimSpace(line[commentIdx+1:])
internal/ui/footer.go
@@ -15,12 +15,14 @@ func Footer(styles Styles, activeSection int, width int) string {
 		keys = []string{"↑↓: navigate", "space: toggle allow/deny"}
 
 	case sections.RulesSection:
-		keys = []string{"↑↓: navigate", "enter: actions", "i: info", "d: delete"}
+		keys = []string{"enter: actions", "i: info", "a: add rule", "d: delete rule", "s: switch IPv4/IPv6"}
 	default:
 		keys = []string{"tab: next section", "r: refresh"}
 	}
 
-	keys = append(keys, "q: quit")
-	footerText := strings.Join(keys, "  |  ")
+	if len(keys) < 3 {
+		keys = append(keys, "q: quit")
+	}
+	footerText := strings.Join(keys, " | ")
 	return styles.Footer.Width(width).Render(footerText)
 }