Browse Source

feat(cli): add autocomplete with double-tab options for all subsections and parameters

Proxmox Router 1 tuần trước cách đây
mục cha
commit
de5cc97727
6 tập tin đã thay đổi với 1165 bổ sung và 0 xóa
  1. BIN
      bin/debtik-cli
  2. BIN
      bin/proxmox-router
  3. 801 0
      internal/cli/completer.go
  4. 358 0
      internal/cli/completer_test.go
  5. 3 0
      internal/cli/server.go
  6. 3 0
      internal/cli/ssh.go

BIN
bin/debtik-cli


BIN
bin/proxmox-router


+ 801 - 0
internal/cli/completer.go

@@ -0,0 +1,801 @@
+package cli
+
+import (
+	"sort"
+	"strings"
+	"sync"
+	"time"
+)
+
+// TerminalWriter is an interface satisfied by *terminal.Terminal and mock terminals.
+type TerminalWriter interface {
+	Write(buf []byte) (n int, err error)
+}
+
+// Node represents a command or subsection in the CLI hierarchy.
+type Node struct {
+	Name        string
+	Aliases     []string
+	Description string
+	Children    []*Node
+	Dynamic     func() []string
+}
+
+// AddChild appends a child node to this node and returns the child.
+func (n *Node) AddChild(child *Node) *Node {
+	n.Children = append(n.Children, child)
+	return child
+}
+
+// FindChild searches for a child matching token by Name, Aliases, or unique prefix.
+func (n *Node) FindChild(token string) *Node {
+	token = strings.ToLower(token)
+	// 1. Exact Name match
+	for _, ch := range n.Children {
+		if strings.ToLower(ch.Name) == token {
+			return ch
+		}
+	}
+	// 2. Exact Aliases match
+	for _, ch := range n.Children {
+		for _, a := range ch.Aliases {
+			if strings.ToLower(a) == token {
+				return ch
+			}
+		}
+	}
+	// 3. Unique prefix match among children (excluding parameters ending in '=')
+	var match *Node
+	for _, ch := range n.Children {
+		if !strings.HasSuffix(ch.Name, "=") && strings.HasPrefix(strings.ToLower(ch.Name), token) {
+			if match != nil {
+				return nil // Ambiguous
+			}
+			match = ch
+		}
+	}
+	return match
+}
+
+// Completer manages tab-completion state and double-tab candidate listing.
+type Completer struct {
+	term        TerminalWriter
+	executor    *Executor
+	root        *Node
+	termWidth   int
+	mu          sync.Mutex
+	lastTabTime time.Time
+	lastTabLine string
+	lastTabPos  int
+}
+
+// NewCompleter creates and initializes a Completer with the full command tree.
+func NewCompleter(term TerminalWriter, executor *Executor) *Completer {
+	c := &Completer{
+		term:      term,
+		executor:  executor,
+		termWidth: 80,
+	}
+	c.root = c.buildCommandTree()
+	return c
+}
+
+// SetWidth sets the terminal width for candidate column formatting.
+func (c *Completer) SetWidth(w int) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	if w > 20 {
+		c.termWidth = w
+	}
+}
+
+// Callback is the AutoCompleteCallback for golang.org/x/term.Terminal.
+func (c *Completer) Callback(line string, pos int, key rune) (newLine string, newPos int, ok bool) {
+	if key != '\t' {
+		return "", 0, false
+	}
+
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	candidates, prefixBeforeWord, currentWord, isParamVal, paramPrefix := c.getCompletions(line, pos)
+	if len(candidates) == 0 {
+		// Nothing to complete
+		c.lastTabTime = time.Time{}
+		c.lastTabLine = ""
+		c.lastTabPos = 0
+		return "", 0, false
+	}
+
+	suffix := ""
+	if pos < len(line) {
+		suffix = line[pos:]
+	}
+
+	// Single match: complete it immediately!
+	if len(candidates) == 1 {
+		c.lastTabTime = time.Time{}
+		c.lastTabLine = ""
+		c.lastTabPos = 0
+
+		match := candidates[0]
+		completedToken := match
+		if isParamVal {
+			completedToken = paramPrefix + match + " "
+		} else if strings.HasSuffix(match, "=") {
+			// Parameter key, e.g. "address=" -> no trailing space
+			completedToken = match
+		} else {
+			completedToken = match + " "
+		}
+
+		newLine = prefixBeforeWord + completedToken + suffix
+		newPos = len(prefixBeforeWord) + len(completedToken)
+		return newLine, newPos, true
+	}
+
+	// Multiple candidates: find Longest Common Prefix (LCP)
+	lcp := longestCommonPrefix(candidates)
+	targetWord := currentWord
+	if isParamVal {
+		targetWord = currentWord[len(paramPrefix):]
+	}
+
+	// If LCP can extend the current input, expand it
+	if len(lcp) > len(targetWord) {
+		extendedToken := lcp
+		if isParamVal {
+			extendedToken = paramPrefix + lcp
+		}
+		newLine = prefixBeforeWord + extendedToken + suffix
+		newPos = len(prefixBeforeWord) + len(extendedToken)
+
+		// Record tab state so an immediate second tab shows remaining options
+		c.lastTabTime = time.Now()
+		c.lastTabLine = newLine
+		c.lastTabPos = newPos
+		return newLine, newPos, true
+	}
+
+	// LCP cannot extend input further: check for 2nd Tab (within 1000ms)
+	isSecondTab := time.Since(c.lastTabTime) <= 1000*time.Millisecond &&
+		c.lastTabLine == line &&
+		c.lastTabPos == pos
+
+	if !isSecondTab {
+		// 1st Tab: Record state and wait for potential 2nd Tab
+		c.lastTabTime = time.Now()
+		c.lastTabLine = line
+		c.lastTabPos = pos
+		return line, pos, true
+	}
+
+	// 2nd Tab: DISPLAY ALL CANDIDATES!
+	c.lastTabTime = time.Time{}
+	c.lastTabLine = ""
+	c.lastTabPos = 0
+
+	if c.term != nil {
+		formatted := c.formatCandidates(candidates)
+		_, _ = c.term.Write([]byte(formatted))
+	}
+
+	return line, pos, true
+}
+
+// getCompletions computes the candidate completions for the current cursor context.
+func (c *Completer) getCompletions(line string, pos int) (candidates []string, prefixBeforeWord string, currentWord string, isParamVal bool, paramPrefix string) {
+	if pos > len(line) {
+		pos = len(line)
+	}
+	input := line[:pos]
+
+	lastSpace := strings.LastIndexAny(input, " \t")
+	if lastSpace == -1 {
+		// Single word so far (or empty)
+		if strings.HasPrefix(input, "/") {
+			prefixBeforeWord = "/"
+			currentWord = input[1:]
+		} else {
+			prefixBeforeWord = ""
+			currentWord = input
+		}
+	} else {
+		prefixBeforeWord = input[:lastSpace+1]
+		currentWord = input[lastSpace+1:]
+	}
+
+	// Parse preceding words to navigate tree
+	rawPrefix := strings.TrimSpace(prefixBeforeWord)
+	hasSlash := strings.HasPrefix(rawPrefix, "/")
+	if hasSlash {
+		rawPrefix = strings.TrimPrefix(rawPrefix, "/")
+	}
+
+	var precedingTokens []string
+	if rawPrefix != "" {
+		precedingTokens = strings.Fields(rawPrefix)
+	}
+
+	// Traverse tree to find current node and track used parameter keys
+	currNode := c.root
+	usedParams := make(map[string]bool)
+
+	for _, token := range precedingTokens {
+		if idx := strings.Index(token, "="); idx != -1 {
+			// Parameter key=value, stay at current node
+			key := strings.ToLower(token[:idx+1])
+			usedParams[key] = true
+			continue
+		}
+		next := currNode.FindChild(token)
+		if next != nil {
+			currNode = next
+		}
+	}
+
+	// Check if user is typing a parameter value (e.g. "interface=eth" or "role=")
+	if idx := strings.Index(currentWord, "="); idx != -1 {
+		paramKey := strings.ToLower(currentWord[:idx+1])
+		paramVal := strings.ToLower(currentWord[idx+1:])
+		paramChild := currNode.FindChild(paramKey)
+		if paramChild != nil && paramChild.Dynamic != nil {
+			isParamVal = true
+			paramPrefix = currentWord[:idx+1]
+			for _, val := range paramChild.Dynamic() {
+				if strings.HasPrefix(strings.ToLower(val), paramVal) {
+					candidates = append(candidates, val)
+				}
+			}
+			sort.Strings(candidates)
+			return
+		}
+	}
+
+	// Gather child commands and parameters
+	search := strings.ToLower(currentWord)
+	for _, ch := range currNode.Children {
+		nameLower := strings.ToLower(ch.Name)
+		if strings.HasSuffix(nameLower, "=") {
+			if usedParams[nameLower] {
+				continue // parameter already provided
+			}
+		}
+		if strings.HasPrefix(nameLower, search) {
+			candidates = append(candidates, ch.Name)
+		}
+	}
+
+	// Check if current node has dynamic positional choices (e.g. interface names or service names)
+	if currNode.Dynamic != nil {
+		for _, dyn := range currNode.Dynamic() {
+			if strings.HasPrefix(strings.ToLower(dyn), search) {
+				candidates = append(candidates, dyn)
+			}
+		}
+	}
+
+	// Deduplicate and sort
+	candidates = uniqueStrings(candidates)
+	sort.Strings(candidates)
+	return
+}
+
+func (c *Completer) formatCandidates(candidates []string) string {
+	if len(candidates) == 0 {
+		return ""
+	}
+
+	termWidth := c.termWidth
+	if termWidth < 40 {
+		termWidth = 80
+	}
+
+	maxLen := 0
+	for _, cand := range candidates {
+		if len(cand) > maxLen {
+			maxLen = len(cand)
+		}
+	}
+
+	colWidth := maxLen + 3
+	if colWidth < 10 {
+		colWidth = 10
+	}
+
+	numCols := (termWidth - 4) / colWidth
+	if numCols < 1 {
+		numCols = 1
+	}
+	if numCols > len(candidates) {
+		numCols = len(candidates)
+	}
+
+	numRows := (len(candidates) + numCols - 1) / numCols
+
+	var sb strings.Builder
+	sb.WriteString("\n")
+
+	for r := 0; r < numRows; r++ {
+		sb.WriteString("  ")
+		for col := 0; col < numCols; col++ {
+			idx := col*numRows + r
+			if idx < len(candidates) {
+				cand := candidates[idx]
+				colored := colorizeCandidate(cand)
+				sb.WriteString(colored)
+				if col < numCols-1 && (col+1)*numRows+r < len(candidates) {
+					pad := colWidth - len(cand)
+					if pad > 0 {
+						sb.WriteString(strings.Repeat(" ", pad))
+					}
+				}
+			}
+		}
+		sb.WriteString("\n")
+	}
+
+	return sb.String()
+}
+
+func colorizeCandidate(s string) string {
+	if strings.HasSuffix(s, "=") {
+		return ColorYellow + s + ColorReset
+	}
+	switch s {
+	case "print", "add", "remove", "enable", "disable", "set", "reboot", "flush", "renew", "release", "connect", "disconnect":
+		return ColorGreen + s + ColorReset
+	case "interface", "ip", "routing", "tool", "log", "user", "system",
+		"vlan", "bridge", "wireguard", "eoip", "vrrp", "pppoe-client",
+		"address", "route", "dhcp-client", "dhcp-server", "dns", "firewall", "service",
+		"filter", "nat", "mangle", "address-list", "bgp", "ospf", "port", "peers", "lease", "network", "cache", "static", "rule", "table", "instance", "area", "neighbor", "resource", "identity":
+		return ColorCyan + s + ColorReset
+	case "api", "api-ssl", "ftp", "ssh", "telnet", "web", "web-ssl":
+		return ColorMagenta + s + ColorReset
+	default:
+		return ColorWhite + s + ColorReset
+	}
+}
+
+func longestCommonPrefix(strs []string) string {
+	if len(strs) == 0 {
+		return ""
+	}
+	prefix := strs[0]
+	for _, s := range strs[1:] {
+		for !strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) {
+			if len(prefix) == 0 {
+				return ""
+			}
+			prefix = prefix[:len(prefix)-1]
+		}
+	}
+	return prefix
+}
+
+func uniqueStrings(slice []string) []string {
+	seen := make(map[string]bool)
+	var result []string
+	for _, s := range slice {
+		if !seen[s] {
+			seen[s] = true
+			result = append(result, s)
+		}
+	}
+	return result
+}
+
+// buildCommandTree creates the full tree of commands, subsections, actions, parameters, and dynamic providers.
+func (c *Completer) buildCommandTree() *Node {
+	root := &Node{Name: "root"}
+
+	// Helper for dynamic interfaces
+	dynInterfaces := func() []string {
+		if c.executor != nil && c.executor.netMgr != nil {
+			ifaces, err := c.executor.netMgr.ListInterfaces()
+			if err == nil {
+				var names []string
+				for _, iface := range ifaces {
+					names = append(names, iface.Name)
+				}
+				return names
+			}
+		}
+		return []string{"eth0", "eth1", "lo"}
+	}
+
+	// Helper for services
+	dynServices := func() []string {
+		return []string{"api", "api-ssl", "ftp", "ssh", "telnet", "web", "web-ssl"}
+	}
+
+	// Common standard actions
+	addPrint := func(n *Node) {
+		n.AddChild(&Node{Name: "print", Aliases: []string{"pr", "show", "list"}})
+	}
+	addRemove := func(n *Node) {
+		n.AddChild(&Node{Name: "remove", Aliases: []string{"rem", "delete"}})
+	}
+	addEnableDisable := func(n *Node) {
+		n.AddChild(&Node{Name: "enable"})
+		n.AddChild(&Node{Name: "disable"})
+	}
+
+	// 1. /interface
+	ifaceNode := root.AddChild(&Node{Name: "interface", Aliases: []string{"interfaces"}})
+	addPrint(ifaceNode)
+	ifaceNode.AddChild(&Node{Name: "enable", Dynamic: dynInterfaces})
+	ifaceNode.AddChild(&Node{Name: "disable", Dynamic: dynInterfaces})
+	ifaceSet := ifaceNode.AddChild(&Node{Name: "set", Dynamic: dynInterfaces})
+	ifaceSet.AddChild(&Node{Name: "mtu="})
+
+	// /interface vlan
+	vlanNode := ifaceNode.AddChild(&Node{Name: "vlan", Aliases: []string{"vlans"}})
+	addPrint(vlanNode)
+	addRemove(vlanNode)
+	vlanAdd := vlanNode.AddChild(&Node{Name: "add"})
+	vlanAdd.AddChild(&Node{Name: "name="})
+	vlanAdd.AddChild(&Node{Name: "vlan-id="})
+	vlanAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+
+	// /interface bridge
+	bridgeNode := ifaceNode.AddChild(&Node{Name: "bridge", Aliases: []string{"bridges"}})
+	addPrint(bridgeNode)
+	addRemove(bridgeNode)
+	addEnableDisable(bridgeNode)
+	bridgeAdd := bridgeNode.AddChild(&Node{Name: "add"})
+	bridgeAdd.AddChild(&Node{Name: "name="})
+
+	// /interface bridge port
+	bridgePortNode := bridgeNode.AddChild(&Node{Name: "port", Aliases: []string{"ports"}})
+	addPrint(bridgePortNode)
+	addRemove(bridgePortNode)
+	addEnableDisable(bridgePortNode)
+	bridgePortAdd := bridgePortNode.AddChild(&Node{Name: "add"})
+	bridgePortAdd.AddChild(&Node{Name: "bridge="})
+	bridgePortAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	bridgePortAdd.AddChild(&Node{Name: "pvid="})
+
+	// /interface wireguard
+	wgNode := ifaceNode.AddChild(&Node{Name: "wireguard", Aliases: []string{"wg"}})
+	addPrint(wgNode)
+	addRemove(wgNode)
+	addEnableDisable(wgNode)
+	wgAdd := wgNode.AddChild(&Node{Name: "add"})
+	wgAdd.AddChild(&Node{Name: "name="})
+	wgAdd.AddChild(&Node{Name: "listen-port="})
+	wgAdd.AddChild(&Node{Name: "private-key="})
+
+	// /interface wireguard peers
+	wgPeers := wgNode.AddChild(&Node{Name: "peers", Aliases: []string{"peer"}})
+	addPrint(wgPeers)
+	addRemove(wgPeers)
+	wgPeersAdd := wgPeers.AddChild(&Node{Name: "add"})
+	wgPeersAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	wgPeersAdd.AddChild(&Node{Name: "public-key="})
+	wgPeersAdd.AddChild(&Node{Name: "allowed-address="})
+	wgPeersAdd.AddChild(&Node{Name: "endpoint-address="})
+	wgPeersAdd.AddChild(&Node{Name: "endpoint-port="})
+
+	// /interface eoip
+	eoipNode := ifaceNode.AddChild(&Node{Name: "eoip"})
+	addPrint(eoipNode)
+	addRemove(eoipNode)
+	addEnableDisable(eoipNode)
+	eoipAdd := eoipNode.AddChild(&Node{Name: "add"})
+	eoipAdd.AddChild(&Node{Name: "name="})
+	eoipAdd.AddChild(&Node{Name: "remote-ip="})
+	eoipAdd.AddChild(&Node{Name: "tunnel-id="})
+
+	// /interface vrrp
+	vrrpNode := ifaceNode.AddChild(&Node{Name: "vrrp"})
+	addPrint(vrrpNode)
+	addRemove(vrrpNode)
+	addEnableDisable(vrrpNode)
+	vrrpAdd := vrrpNode.AddChild(&Node{Name: "add"})
+	vrrpAdd.AddChild(&Node{Name: "name="})
+	vrrpAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	vrrpAdd.AddChild(&Node{Name: "vrid="})
+	vrrpAdd.AddChild(&Node{Name: "priority="})
+
+	// /interface pppoe-client
+	pppoeNode := ifaceNode.AddChild(&Node{Name: "pppoe-client"})
+	addPrint(pppoeNode)
+	addRemove(pppoeNode)
+	pppoeNode.AddChild(&Node{Name: "connect"})
+	pppoeNode.AddChild(&Node{Name: "disconnect"})
+	pppoeAdd := pppoeNode.AddChild(&Node{Name: "add"})
+	pppoeAdd.AddChild(&Node{Name: "name="})
+	pppoeAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	pppoeAdd.AddChild(&Node{Name: "user="})
+	pppoeAdd.AddChild(&Node{Name: "password="})
+
+	// 2. /ip
+	ipNode := root.AddChild(&Node{Name: "ip"})
+
+	// /ip address
+	ipAddrNode := ipNode.AddChild(&Node{Name: "address", Aliases: []string{"addr"}})
+	addPrint(ipAddrNode)
+	addRemove(ipAddrNode)
+	ipAddrAdd := ipAddrNode.AddChild(&Node{Name: "add"})
+	ipAddrAdd.AddChild(&Node{Name: "address="})
+	ipAddrAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	ipAddrAdd.AddChild(&Node{Name: "network="})
+
+	// /ip route
+	ipRouteNode := ipNode.AddChild(&Node{Name: "route", Aliases: []string{"routes"}})
+	addPrint(ipRouteNode)
+	addRemove(ipRouteNode)
+	addEnableDisable(ipRouteNode)
+	ipRouteAdd := ipRouteNode.AddChild(&Node{Name: "add"})
+	ipRouteAdd.AddChild(&Node{Name: "dst-address="})
+	ipRouteAdd.AddChild(&Node{Name: "gateway="})
+	ipRouteAdd.AddChild(&Node{Name: "distance="})
+
+	// /ip route rule
+	ipRouteRule := ipRouteNode.AddChild(&Node{Name: "rule", Aliases: []string{"rules"}})
+	addPrint(ipRouteRule)
+	addRemove(ipRouteRule)
+	ipRouteRuleAdd := ipRouteRule.AddChild(&Node{Name: "add"})
+	ipRouteRuleAdd.AddChild(&Node{Name: "src-address="})
+	ipRouteRuleAdd.AddChild(&Node{Name: "dst-address="})
+	ipRouteRuleAdd.AddChild(&Node{Name: "table="})
+	ipRouteRuleAdd.AddChild(&Node{Name: "action=", Dynamic: func() []string { return []string{"lookup", "unreachable", "drop"} }})
+
+	// /ip route table
+	ipRouteTable := ipRouteNode.AddChild(&Node{Name: "table", Aliases: []string{"tables"}})
+	addPrint(ipRouteTable)
+	addRemove(ipRouteTable)
+	ipRouteTableAdd := ipRouteTable.AddChild(&Node{Name: "add"})
+	ipRouteTableAdd.AddChild(&Node{Name: "name="})
+	ipRouteTableAdd.AddChild(&Node{Name: "fib="})
+
+	// /ip dhcp-client
+	dhcpCliNode := ipNode.AddChild(&Node{Name: "dhcp-client"})
+	addPrint(dhcpCliNode)
+	addRemove(dhcpCliNode)
+	dhcpCliNode.AddChild(&Node{Name: "renew"})
+	dhcpCliNode.AddChild(&Node{Name: "release"})
+	dhcpCliAdd := dhcpCliNode.AddChild(&Node{Name: "add"})
+	dhcpCliAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	dhcpCliAdd.AddChild(&Node{Name: "disabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	// /ip dhcp-server
+	dhcpSrvNode := ipNode.AddChild(&Node{Name: "dhcp-server"})
+	addPrint(dhcpSrvNode)
+	addRemove(dhcpSrvNode)
+	addEnableDisable(dhcpSrvNode)
+	dhcpSrvAdd := dhcpSrvNode.AddChild(&Node{Name: "add"})
+	dhcpSrvAdd.AddChild(&Node{Name: "name="})
+	dhcpSrvAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	dhcpSrvAdd.AddChild(&Node{Name: "address-pool="})
+	dhcpSrvAdd.AddChild(&Node{Name: "lease-time="})
+
+	// /ip dhcp-server lease
+	dhcpLeaseNode := dhcpSrvNode.AddChild(&Node{Name: "lease", Aliases: []string{"leases"}})
+	addPrint(dhcpLeaseNode)
+	addRemove(dhcpLeaseNode)
+
+	// /ip dhcp-server network
+	dhcpNetNode := dhcpSrvNode.AddChild(&Node{Name: "network", Aliases: []string{"networks"}})
+	addPrint(dhcpNetNode)
+	addRemove(dhcpNetNode)
+	dhcpNetAdd := dhcpNetNode.AddChild(&Node{Name: "add"})
+	dhcpNetAdd.AddChild(&Node{Name: "address="})
+	dhcpNetAdd.AddChild(&Node{Name: "gateway="})
+	dhcpNetAdd.AddChild(&Node{Name: "dns-server="})
+
+	// /ip dns
+	dnsNode := ipNode.AddChild(&Node{Name: "dns"})
+	addPrint(dnsNode)
+	dnsSet := dnsNode.AddChild(&Node{Name: "set"})
+	dnsSet.AddChild(&Node{Name: "servers="})
+	dnsSet.AddChild(&Node{Name: "allow-remote-requests=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	dnsCache := dnsNode.AddChild(&Node{Name: "cache"})
+	addPrint(dnsCache)
+	dnsCache.AddChild(&Node{Name: "flush"})
+
+	dnsStatic := dnsNode.AddChild(&Node{Name: "static"})
+	addPrint(dnsStatic)
+	addRemove(dnsStatic)
+	dnsStaticAdd := dnsStatic.AddChild(&Node{Name: "add"})
+	dnsStaticAdd.AddChild(&Node{Name: "name="})
+	dnsStaticAdd.AddChild(&Node{Name: "address="})
+
+	// /ip firewall
+	fwNode := ipNode.AddChild(&Node{Name: "firewall"})
+
+	// /ip firewall filter
+	fwFilter := fwNode.AddChild(&Node{Name: "filter"})
+	addPrint(fwFilter)
+	addRemove(fwFilter)
+	addEnableDisable(fwFilter)
+	fwFilterAdd := fwFilter.AddChild(&Node{Name: "add"})
+	fwFilterAdd.AddChild(&Node{Name: "chain=", Dynamic: func() []string { return []string{"forward", "input", "output"} }})
+	fwFilterAdd.AddChild(&Node{Name: "action=", Dynamic: func() []string { return []string{"accept", "drop", "reject"} }})
+	fwFilterAdd.AddChild(&Node{Name: "protocol=", Dynamic: func() []string { return []string{"icmp", "tcp", "udp"} }})
+	fwFilterAdd.AddChild(&Node{Name: "src-address="})
+	fwFilterAdd.AddChild(&Node{Name: "dst-address="})
+	fwFilterAdd.AddChild(&Node{Name: "dst-port="})
+	fwFilterAdd.AddChild(&Node{Name: "in-interface=", Dynamic: dynInterfaces})
+	fwFilterAdd.AddChild(&Node{Name: "out-interface=", Dynamic: dynInterfaces})
+	fwFilterAdd.AddChild(&Node{Name: "comment="})
+
+	// /ip firewall nat
+	fwNat := fwNode.AddChild(&Node{Name: "nat"})
+	addPrint(fwNat)
+	addRemove(fwNat)
+	addEnableDisable(fwNat)
+	fwNatAdd := fwNat.AddChild(&Node{Name: "add"})
+	fwNatAdd.AddChild(&Node{Name: "chain=", Dynamic: func() []string { return []string{"dstnat", "srcnat"} }})
+	fwNatAdd.AddChild(&Node{Name: "action=", Dynamic: func() []string { return []string{"accept", "dst-nat", "masquerade", "redirect", "src-nat"} }})
+	fwNatAdd.AddChild(&Node{Name: "protocol=", Dynamic: func() []string { return []string{"icmp", "tcp", "udp"} }})
+	fwNatAdd.AddChild(&Node{Name: "src-address="})
+	fwNatAdd.AddChild(&Node{Name: "dst-address="})
+	fwNatAdd.AddChild(&Node{Name: "to-addresses="})
+	fwNatAdd.AddChild(&Node{Name: "to-ports="})
+	fwNatAdd.AddChild(&Node{Name: "out-interface=", Dynamic: dynInterfaces})
+	fwNatAdd.AddChild(&Node{Name: "comment="})
+
+	// /ip firewall mangle
+	fwMangle := fwNode.AddChild(&Node{Name: "mangle"})
+	addPrint(fwMangle)
+	addRemove(fwMangle)
+	addEnableDisable(fwMangle)
+	fwMangleAdd := fwMangle.AddChild(&Node{Name: "add"})
+	fwMangleAdd.AddChild(&Node{Name: "chain=", Dynamic: func() []string { return []string{"forward", "input", "output", "postrouting", "prerouting"} }})
+	fwMangleAdd.AddChild(&Node{Name: "action=", Dynamic: func() []string { return []string{"accept", "mark-packet", "mark-routing"} }})
+	fwMangleAdd.AddChild(&Node{Name: "protocol=", Dynamic: func() []string { return []string{"icmp", "tcp", "udp"} }})
+	fwMangleAdd.AddChild(&Node{Name: "src-address="})
+	fwMangleAdd.AddChild(&Node{Name: "dst-address="})
+
+	// /ip firewall address-list
+	fwAddrList := fwNode.AddChild(&Node{Name: "address-list"})
+	addPrint(fwAddrList)
+	addRemove(fwAddrList)
+	addEnableDisable(fwAddrList)
+	fwAddrListAdd := fwAddrList.AddChild(&Node{Name: "add"})
+	fwAddrListAdd.AddChild(&Node{Name: "list="})
+	fwAddrListAdd.AddChild(&Node{Name: "address="})
+	fwAddrListAdd.AddChild(&Node{Name: "comment="})
+
+	// /ip service
+	serviceNode := ipNode.AddChild(&Node{Name: "service", Aliases: []string{"services"}})
+	addPrint(serviceNode)
+	serviceNode.AddChild(&Node{Name: "enable", Dynamic: dynServices})
+	serviceNode.AddChild(&Node{Name: "disable", Dynamic: dynServices})
+	serviceSet := serviceNode.AddChild(&Node{Name: "set", Dynamic: dynServices})
+	serviceSet.AddChild(&Node{Name: "port="})
+	serviceSet.AddChild(&Node{Name: "disabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	// 3. /routing
+	routingNode := root.AddChild(&Node{Name: "routing"})
+
+	// /routing bgp
+	bgpNode := routingNode.AddChild(&Node{Name: "bgp"})
+	bgpInst := bgpNode.AddChild(&Node{Name: "instance"})
+	addPrint(bgpInst)
+	bgpInstSet := bgpInst.AddChild(&Node{Name: "set"})
+	bgpInstSet.AddChild(&Node{Name: "as="})
+	bgpInstSet.AddChild(&Node{Name: "router-id="})
+	bgpInstSet.AddChild(&Node{Name: "enabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	bgpPeer := bgpNode.AddChild(&Node{Name: "peer", Aliases: []string{"peers", "connection"}})
+	addPrint(bgpPeer)
+	addRemove(bgpPeer)
+	addEnableDisable(bgpPeer)
+	bgpPeerAdd := bgpPeer.AddChild(&Node{Name: "add"})
+	bgpPeerAdd.AddChild(&Node{Name: "name="})
+	bgpPeerAdd.AddChild(&Node{Name: "remote-address="})
+	bgpPeerAdd.AddChild(&Node{Name: "remote-as="})
+	bgpPeerAdd.AddChild(&Node{Name: "description="})
+
+	bgpNet := bgpNode.AddChild(&Node{Name: "network"})
+	addPrint(bgpNet)
+	addRemove(bgpNet)
+	bgpNetAdd := bgpNet.AddChild(&Node{Name: "add"})
+	bgpNetAdd.AddChild(&Node{Name: "network="})
+
+	bgpRoutes := bgpNode.AddChild(&Node{Name: "routes"})
+	addPrint(bgpRoutes)
+
+	// /routing ospf
+	ospfNode := routingNode.AddChild(&Node{Name: "ospf"})
+	ospfInst := ospfNode.AddChild(&Node{Name: "instance"})
+	addPrint(ospfInst)
+	ospfInstSet := ospfInst.AddChild(&Node{Name: "set"})
+	ospfInstSet.AddChild(&Node{Name: "router-id="})
+	ospfInstSet.AddChild(&Node{Name: "enabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	ospfArea := ospfNode.AddChild(&Node{Name: "area"})
+	addPrint(ospfArea)
+	addRemove(ospfArea)
+	ospfAreaAdd := ospfArea.AddChild(&Node{Name: "add"})
+	ospfAreaAdd.AddChild(&Node{Name: "name="})
+	ospfAreaAdd.AddChild(&Node{Name: "area-id="})
+
+	ospfIface := ospfNode.AddChild(&Node{Name: "interface"})
+	addPrint(ospfIface)
+	addRemove(ospfIface)
+	ospfIfaceAdd := ospfIface.AddChild(&Node{Name: "add"})
+	ospfIfaceAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	ospfIfaceAdd.AddChild(&Node{Name: "cost="})
+	ospfIfaceAdd.AddChild(&Node{Name: "priority="})
+	ospfIfaceAdd.AddChild(&Node{Name: "network-type=", Dynamic: func() []string { return []string{"broadcast", "point-to-point"} }})
+
+	ospfNeighbor := ospfNode.AddChild(&Node{Name: "neighbor", Aliases: []string{"neighbors"}})
+	addPrint(ospfNeighbor)
+
+	// /routing filter
+	routingFilter := routingNode.AddChild(&Node{Name: "filter"})
+	addPrint(routingFilter)
+	rfRule := routingFilter.AddChild(&Node{Name: "rule", Aliases: []string{"rules"}})
+	addPrint(rfRule)
+	addRemove(rfRule)
+	rfRuleAdd := rfRule.AddChild(&Node{Name: "add"})
+	rfRuleAdd.AddChild(&Node{Name: "chain="})
+	rfRuleAdd.AddChild(&Node{Name: "action=", Dynamic: func() []string { return []string{"accept", "discard", "reject"} }})
+	rfRuleAdd.AddChild(&Node{Name: "prefix="})
+	rfRuleAdd.AddChild(&Node{Name: "prefix-len="})
+
+	// 4. /tool
+	toolNode := root.AddChild(&Node{Name: "tool"})
+	toolPing := toolNode.AddChild(&Node{Name: "ping"})
+	toolPing.AddChild(&Node{Name: "count="})
+	toolPing.AddChild(&Node{Name: "size="})
+	toolPing.AddChild(&Node{Name: "interval="})
+
+	toolTrace := toolNode.AddChild(&Node{Name: "traceroute"})
+	toolTrace.AddChild(&Node{Name: "max-hops="})
+
+	toolBtest := toolNode.AddChild(&Node{Name: "bandwidth-test", Aliases: []string{"btest"}})
+	toolBtest.AddChild(&Node{Name: "target="})
+	toolBtest.AddChild(&Node{Name: "duration="})
+	toolBtest.AddChild(&Node{Name: "direction=", Dynamic: func() []string { return []string{"receive", "send", "both"} }})
+
+	// 5. /log
+	logNode := root.AddChild(&Node{Name: "log"})
+	logPrint := logNode.AddChild(&Node{Name: "print", Aliases: []string{"pr", "show"}})
+	logPrint.AddChild(&Node{Name: "limit="})
+	logPrint.AddChild(&Node{Name: "filter="})
+
+	// 6. /user
+	userNode := root.AddChild(&Node{Name: "user", Aliases: []string{"users"}})
+	addPrint(userNode)
+	addRemove(userNode)
+	userAdd := userNode.AddChild(&Node{Name: "add"})
+	userAdd.AddChild(&Node{Name: "name="})
+	userAdd.AddChild(&Node{Name: "password="})
+	userAdd.AddChild(&Node{Name: "role=", Dynamic: func() []string { return []string{"admin", "full", "read", "viewer"} }})
+	userAdd.AddChild(&Node{Name: "comment="})
+
+	userSet := userNode.AddChild(&Node{Name: "set"})
+	userSet.AddChild(&Node{Name: "name="})
+	userSet.AddChild(&Node{Name: "password="})
+	userSet.AddChild(&Node{Name: "role=", Dynamic: func() []string { return []string{"admin", "full", "read", "viewer"} }})
+	userSet.AddChild(&Node{Name: "comment="})
+	userSet.AddChild(&Node{Name: "disabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
+
+	// 7. /system
+	sysNode := root.AddChild(&Node{Name: "system"})
+	sysRes := sysNode.AddChild(&Node{Name: "resource"})
+	addPrint(sysRes)
+
+	sysIdent := sysNode.AddChild(&Node{Name: "identity"})
+	addPrint(sysIdent)
+	sysIdentSet := sysIdent.AddChild(&Node{Name: "set"})
+	sysIdentSet.AddChild(&Node{Name: "name="})
+
+	sysNode.AddChild(&Node{Name: "reboot"})
+
+	// 8. Direct root utility commands
+	root.AddChild(&Node{Name: "clear"})
+	root.AddChild(&Node{Name: "help", Aliases: []string{"?"}})
+	root.AddChild(&Node{Name: "quit"})
+	root.AddChild(&Node{Name: "exit"})
+	root.AddChild(&Node{Name: "ping"})
+	root.AddChild(&Node{Name: "traceroute"})
+
+	return root
+}

+ 358 - 0
internal/cli/completer_test.go

@@ -0,0 +1,358 @@
+package cli
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+	"time"
+
+	terminal "golang.org/x/term"
+)
+
+type mockTerminalWriter struct {
+	buf bytes.Buffer
+}
+
+func (m *mockTerminalWriter) Write(p []byte) (n int, err error) {
+	return m.buf.Write(p)
+}
+
+func TestCompleterRoot(t *testing.T) {
+	mock := &mockTerminalWriter{}
+	completer := NewCompleter(mock, nil)
+
+	// 1. Single tab on empty line: ambiguous, no advance
+	newLine, newPos, ok := completer.Callback("", 0, '\t')
+	if !ok || newLine != "" || newPos != 0 {
+		t.Fatalf("Expected empty line and ok=true, got newLine=%q, newPos=%d, ok=%v", newLine, newPos, ok)
+	}
+	if mock.buf.Len() != 0 {
+		t.Fatalf("Expected no terminal output on 1st tab, got %q", mock.buf.String())
+	}
+
+	// 2. Second tab immediately after: displays all root options
+	newLine, newPos, ok = completer.Callback("", 0, '\t')
+	if !ok || newLine != "" || newPos != 0 {
+		t.Fatalf("Expected empty line and ok=true, got newLine=%q, newPos=%d, ok=%v", newLine, newPos, ok)
+	}
+	out := mock.buf.String()
+	if !strings.Contains(out, "interface") || !strings.Contains(out, "ip") || !strings.Contains(out, "routing") {
+		t.Fatalf("Expected root commands in double tab output, got: %s", out)
+	}
+}
+
+func TestCompleterSlashRoot(t *testing.T) {
+	mock := &mockTerminalWriter{}
+	completer := NewCompleter(mock, nil)
+
+	// 1. Single tab on "/": ambiguous, no advance
+	newLine, newPos, ok := completer.Callback("/", 1, '\t')
+	if !ok || newLine != "/" || newPos != 1 {
+		t.Fatalf("Expected '/' and ok=true, got %q", newLine)
+	}
+
+	// 2. Second tab on "/": displays root choices
+	newLine, newPos, ok = completer.Callback("/", 1, '\t')
+	if !ok || newLine != "/" {
+		t.Fatalf("Expected '/' and ok=true, got %q", newLine)
+	}
+	out := mock.buf.String()
+	if !strings.Contains(out, "interface") || !strings.Contains(out, "ip") {
+		t.Fatalf("Expected options on double tab, got: %s", out)
+	}
+}
+
+func TestCompleterUniqueMatch(t *testing.T) {
+	mock := &mockTerminalWriter{}
+	completer := NewCompleter(mock, nil)
+
+	// "/in" should complete to "/interface " on first tab
+	newLine, newPos, ok := completer.Callback("/in", 3, '\t')
+	if !ok {
+		t.Fatalf("Expected ok=true")
+	}
+	if newLine != "/interface " {
+		t.Fatalf("Expected '/interface ', got %q", newLine)
+	}
+	if newPos != len("/interface ") {
+		t.Fatalf("Expected newPos=%d, got %d", len("/interface "), newPos)
+	}
+
+	// "rout" should complete to "routing " on first tab
+	newLine, newPos, ok = completer.Callback("rout", 4, '\t')
+	if !ok || newLine != "routing " {
+		t.Fatalf("Expected 'routing ', got %q", newLine)
+	}
+}
+
+func TestCompleterCommonPrefix(t *testing.T) {
+	mock := &mockTerminalWriter{}
+	completer := NewCompleter(mock, nil)
+
+	// "/ip dh" matches "dhcp-client" and "dhcp-server" -> LCP is "dhcp-"
+	newLine, newPos, ok := completer.Callback("/ip dh", 6, '\t')
+	if !ok || newLine != "/ip dhcp-" {
+		t.Fatalf("Expected '/ip dhcp-', got %q", newLine)
+	}
+	if newPos != len("/ip dhcp-") {
+		t.Fatalf("Expected newPos=%d, got %d", len("/ip dhcp-"), newPos)
+	}
+
+	// Immediate second tab should show the two options
+	mock.buf.Reset()
+	newLine, newPos, ok = completer.Callback(newLine, newPos, '\t')
+	if !ok || newLine != "/ip dhcp-" {
+		t.Fatalf("Expected '/ip dhcp-', got %q", newLine)
+	}
+	out := mock.buf.String()
+	if !strings.Contains(out, "dhcp-client") || !strings.Contains(out, "dhcp-server") {
+		t.Fatalf("Expected dhcp choices, got: %s", out)
+	}
+}
+
+func TestCompleterSubsectionsDoubleTab(t *testing.T) {
+	mock := &mockTerminalWriter{}
+	completer := NewCompleter(mock, nil)
+
+	subsections := []struct {
+		input       string
+		expectedIn  []string
+		description string
+	}{
+		{
+			input:       "/ip ",
+			expectedIn:  []string{"address", "dhcp-client", "dhcp-server", "dns", "firewall", "route", "service"},
+			description: "/ip subsections",
+		},
+		{
+			input:       "/interface ",
+			expectedIn:  []string{"bridge", "eoip", "pppoe-client", "vlan", "vrrp", "wireguard", "print", "enable", "disable"},
+			description: "/interface subsections",
+		},
+		{
+			input:       "/interface bridge ",
+			expectedIn:  []string{"add", "disable", "enable", "port", "print", "remove"},
+			description: "/interface bridge actions and port",
+		},
+		{
+			input:       "/interface bridge port ",
+			expectedIn:  []string{"add", "disable", "enable", "print", "remove"},
+			description: "/interface bridge port actions",
+		},
+		{
+			input:       "/ip firewall ",
+			expectedIn:  []string{"address-list", "filter", "mangle", "nat"},
+			description: "/ip firewall subsections",
+		},
+		{
+			input:       "/ip firewall filter ",
+			expectedIn:  []string{"add", "disable", "enable", "print", "remove"},
+			description: "/ip firewall filter actions",
+		},
+		{
+			input:       "/ip service ",
+			expectedIn:  []string{"disable", "enable", "print", "set"},
+			description: "/ip service actions",
+		},
+		{
+			input:       "/ip service enable ",
+			expectedIn:  []string{"api", "ftp", "ssh", "telnet", "web"},
+			description: "/ip service enable service choices",
+		},
+		{
+			input:       "/routing ",
+			expectedIn:  []string{"bgp", "filter", "ospf"},
+			description: "/routing subsections",
+		},
+		{
+			input:       "/routing bgp ",
+			expectedIn:  []string{"instance", "network", "peer", "routes"},
+			description: "/routing bgp subsections",
+		},
+		{
+			input:       "/routing ospf ",
+			expectedIn:  []string{"area", "instance", "interface", "neighbor"},
+			description: "/routing ospf subsections",
+		},
+		{
+			input:       "/tool ",
+			expectedIn:  []string{"bandwidth-test", "ping", "traceroute"},
+			description: "/tool subsections",
+		},
+		{
+			input:       "/user ",
+			expectedIn:  []string{"add", "print", "remove", "set"},
+			description: "/user actions",
+		},
+		{
+			input:       "/system ",
+			expectedIn:  []string{"identity", "reboot", "resource"},
+			description: "/system subsections",
+		},
+		{
+			input:       "/system identity ",
+			expectedIn:  []string{"print", "set"},
+			description: "/system identity actions",
+		},
+	}
+
+	for _, tc := range subsections {
+		t.Run(tc.description, func(t *testing.T) {
+			mock.buf.Reset()
+			// Tab 1
+			completer.Callback(tc.input, len(tc.input), '\t')
+			// Tab 2
+			completer.Callback(tc.input, len(tc.input), '\t')
+			out := mock.buf.String()
+			for _, expected := range tc.expectedIn {
+				if !strings.Contains(out, expected) {
+					t.Errorf("In %s, expected %q in output, got: %s", tc.description, expected, out)
+				}
+			}
+		})
+	}
+}
+
+func TestCompleterParameters(t *testing.T) {
+	mock := &mockTerminalWriter{}
+	completer := NewCompleter(mock, nil)
+
+	// Under /ip firewall filter add, double tab should show parameter options
+	mock.buf.Reset()
+	completer.Callback("/ip firewall filter add ", len("/ip firewall filter add "), '\t')
+	completer.Callback("/ip firewall filter add ", len("/ip firewall filter add "), '\t')
+	out := mock.buf.String()
+	expectedParams := []string{"action=", "chain=", "comment=", "dst-address=", "protocol=", "src-address="}
+	for _, p := range expectedParams {
+		if !strings.Contains(out, p) {
+			t.Errorf("Expected param %q in output, got: %s", p, out)
+		}
+	}
+
+	// Complete "ch" -> "chain=" (no space after =)
+	newLine, _, ok := completer.Callback("/ip firewall filter add ch", len("/ip firewall filter add ch"), '\t')
+	if !ok || newLine != "/ip firewall filter add chain=" {
+		t.Fatalf("Expected parameter completion 'chain=', got %q", newLine)
+	}
+
+	// At "chain=", 2 tabs should show values ("forward", "input", "output")
+	mock.buf.Reset()
+	completer.Callback(newLine, len(newLine), '\t')
+	completer.Callback(newLine, len(newLine), '\t')
+	out = mock.buf.String()
+	if !strings.Contains(out, "forward") || !strings.Contains(out, "input") || !strings.Contains(out, "output") {
+		t.Fatalf("Expected chain values, got: %s", out)
+	}
+
+	// Complete value "chain=f" -> "chain=forward " (with trailing space!)
+	newLine, _, ok = completer.Callback("/ip firewall filter add chain=f", len("/ip firewall filter add chain=f"), '\t')
+	if !ok || newLine != "/ip firewall filter add chain=forward " {
+		t.Fatalf("Expected 'chain=forward ', got %q", newLine)
+	}
+
+	// Once "chain=forward " is used, double tab should NOT suggest "chain=" again
+	mock.buf.Reset()
+	completer.Callback(newLine, len(newLine), '\t')
+	completer.Callback(newLine, len(newLine), '\t')
+	out = mock.buf.String()
+	if strings.Contains(out, "chain=") {
+		t.Fatalf("Expected already used 'chain=' to be omitted, got: %s", out)
+	}
+	if !strings.Contains(out, "action=") {
+		t.Fatalf("Expected 'action=' still present, got: %s", out)
+	}
+}
+
+func TestCompleterDoubleTabTimeout(t *testing.T) {
+	mock := &mockTerminalWriter{}
+	completer := NewCompleter(mock, nil)
+
+	// Press 1
+	completer.Callback("/ip ", 4, '\t')
+
+	// Artificially advance time past timeout
+	completer.lastTabTime = time.Now().Add(-1500 * time.Millisecond)
+
+	// Press 2 (after timeout): should be treated as new 1st tab, so NO print!
+	mock.buf.Reset()
+	completer.Callback("/ip ", 4, '\t')
+	if mock.buf.Len() != 0 {
+		t.Fatalf("Expected no output after timeout, got: %s", mock.buf.String())
+	}
+
+	// Press 3 (within 1s of Press 2): should be 2nd tab and print!
+	completer.Callback("/ip ", 4, '\t')
+	if mock.buf.Len() == 0 {
+		t.Fatalf("Expected output on 2nd tab within timeout")
+	}
+}
+
+func TestCompleterIgnoredNonTab(t *testing.T) {
+	mock := &mockTerminalWriter{}
+	completer := NewCompleter(mock, nil)
+
+	_, _, ok := completer.Callback("/ip ", 4, 'a')
+	if ok {
+		t.Fatalf("Expected ok=false for non-tab key 'a'")
+	}
+}
+
+type testTerminalRW struct {
+	in  bytes.Buffer
+	out bytes.Buffer
+}
+
+func (rw *testTerminalRW) Read(p []byte) (n int, err error) {
+	return rw.in.Read(p)
+}
+
+func (rw *testTerminalRW) Write(p []byte) (n int, err error) {
+	return rw.out.Write(p)
+}
+
+func TestCompleterWithRealTerminal(t *testing.T) {
+	rw := &testTerminalRW{}
+	term := terminal.NewTerminal(rw, "[admin@DebTik] > ")
+	completer := NewCompleter(term, nil)
+	term.AutoCompleteCallback = completer.Callback
+
+	// Feed input: "/ip " followed by 2 tabs, then "firewall print\r\n"
+	rw.in.WriteString("/ip \t\tfirewall print\r\n")
+
+	line, err := term.ReadLine()
+	if err != nil {
+		t.Fatalf("ReadLine error: %v", err)
+	}
+	if line != "/ip firewall print" {
+		t.Fatalf("Expected '/ip firewall print', got %q", line)
+	}
+
+	out := rw.out.String()
+	// Output should contain the subsections formatted on double tab
+	if !strings.Contains(out, "address") || !strings.Contains(out, "firewall") || !strings.Contains(out, "route") {
+		t.Fatalf("Terminal output missing double-tab options: %s", out)
+	}
+}
+
+func TestCompleterFullInteractiveSession(t *testing.T) {
+	rw := &testTerminalRW{}
+	term := terminal.NewTerminal(rw, "[admin@DebTik] > ")
+	completer := NewCompleter(term, nil)
+	term.AutoCompleteCallback = completer.Callback
+
+	// Type /int<Tab>br<Tab>po<Tab><Tab><Tab>pr<Tab>\r\n
+	rw.in.WriteString("/int\tbr\tpo\t\t\tpr\t\r\n")
+
+	line, err := term.ReadLine()
+	if err != nil {
+		t.Fatalf("ReadLine error: %v", err)
+	}
+	if line != "/interface bridge port print " {
+		t.Fatalf("Expected '/interface bridge port print ', got %q", line)
+	}
+
+	out := rw.out.String()
+	if !strings.Contains(out, "add") || !strings.Contains(out, "disable") || !strings.Contains(out, "print") {
+		t.Fatalf("Expected bridge port options in output, got: %s", out)
+	}
+}

+ 3 - 0
internal/cli/server.go

@@ -166,6 +166,9 @@ func (s *Server) handleConn(rawConn net.Conn) {
 	prompt := fmt.Sprintf("[%s%s%s@%s%s%s] > ", ColorGreen+ColorBold, authenticatedUser.Username, ColorReset, ColorCyan+ColorBold, hostname, ColorReset)
 	term.SetPrompt(prompt)
 
+	completer := NewCompleter(term, s.executor)
+	term.AutoCompleteCallback = completer.Callback
+
 	for {
 		line, err := term.ReadLine()
 		if err != nil {

+ 3 - 0
internal/cli/ssh.go

@@ -247,6 +247,9 @@ func (s *SSHServer) runInteractiveShell(term *terminal.Terminal, user *auth.Publ
 	prompt := fmt.Sprintf("[%s%s%s@%s%s%s] > ", ColorGreen+ColorBold, user.Username, ColorReset, ColorCyan+ColorBold, hostname, ColorReset)
 	term.SetPrompt(prompt)
 
+	completer := NewCompleter(term, s.executor)
+	term.AutoCompleteCallback = completer.Callback
+
 	for {
 		line, err := term.ReadLine()
 		if err != nil {