// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt // SPDX-License-Identifier: Apache-2.0 package cli import ( "context" "fmt" "strings" "time" "github.com/chzyer/readline" ) // defaultCompleteTimeout bounds a single Dynamic lookup triggered by TAB or '?'. const defaultCompleteTimeout = 1 * time.Second // SplitTokens splits a command line into whitespace-separated tokens. func SplitTokens(s string) []string { return strings.Fields(s) } // splitForCompletion separates the confirmed prefix tokens from the partial // token being completed, based on whether the cursor sits after a space. func splitForCompletion(before string) (prefix []string, partial string) { tokens := SplitTokens(before) if len(tokens) == 0 || (len(before) > 0 && before[len(before)-1] == ' ') { return tokens, "" } return tokens[:len(tokens)-1], tokens[len(tokens)-1] } // completer implements readline.AutoCompleter over the command tree. type completer[C any] struct { root *Node[C] client C timeout time.Duration } // Do returns the completion suffixes for the token at the cursor. func (co *completer[C]) Do(line []rune, pos int) (newLine [][]rune, length int) { prefix, partial := splitForCompletion(string(line[:pos])) ctx, cancel := context.WithTimeout(context.Background(), co.timeout) defer cancel() candidates := Candidates(ctx, co.client, co.root, prefix, partial) var suffixes [][]rune for _, c := range candidates { suffixes = append(suffixes, []rune(c.Word[len(partial):]+" ")) } return suffixes, len([]rune(partial)) } // questionListener intercepts '?' and prints inline help at the cursor. type questionListener[C any] struct { root *Node[C] client C rl *readline.Instance timeout time.Duration } func (ql *questionListener[C]) OnChange(line []rune, pos int, key rune) ([]rune, int, bool) { if key != '?' { return line, pos, false } before := strings.TrimSuffix(string(line[:pos]), "?") tokens := SplitTokens(before) prefix, partial := splitForCompletion(before) // Walk the confirmed prefix, then try to advance one more step using the // partial token (via prefix-match or slot fallback), so "sh?" expands "sh" // to "show" and shows show's subtree. node, args, remaining := Walk(ql.root, prefix) displayPrefix := strings.Join(prefix, " ") var unknownMsg string if len(remaining) > 0 { consumed := prefix[:len(prefix)-len(remaining)] unknownMsg = unknownCommandError(consumed, remaining[0]).Error() displayPrefix = strings.Join(consumed, " ") } else if partial != "" { if next := matchFixedChild(node.Children, partial); next != nil { node = next displayPrefix = strings.Join(tokens, " ") } else if slot := findSlotChild(node.Children); slot != nil { node = slot displayPrefix = strings.Join(tokens, " ") } } lines := expandPaths(node, displayPrefix, make(map[*Node[C]]bool)) ctx, cancel := context.WithTimeout(context.Background(), ql.timeout) defer cancel() var dynValues []string var dynWord string if slot := findSlotChild(node.Children); slot != nil { dynValues = slot.Dynamic(ctx, ql.client, args) dynWord = slot.Word } maxLen := 0 for _, l := range lines { if len(l.Path) > maxLen { maxLen = len(l.Path) } } // readline's wrapWriter erases the current input row before each Write and // redraws the prompt after, so echo the full "prompt + line" ourselves as // the first write: it lands on the just-cleaned row, and the help below it // each redraws a fresh prompt. _, _ = fmt.Fprintf(ql.rl.Stderr(), "%s%s\r\n", ql.rl.Config.Prompt, string(line)) if unknownMsg != "" { _, _ = fmt.Fprintf(ql.rl.Stderr(), " %s\r\n", unknownMsg) } if len(lines) == 0 { _, _ = fmt.Fprintf(ql.rl.Stderr(), " \r\n") } else { for _, l := range lines { if l.Help != "" { _, _ = fmt.Fprintf(ql.rl.Stderr(), "%-*s %s\r\n", maxLen+2, l.Path, l.Help) } else { _, _ = fmt.Fprintf(ql.rl.Stderr(), "%s\r\n", l.Path) } } if len(dynValues) > 0 { _, _ = fmt.Fprintf(ql.rl.Stderr(), " %s: %s\r\n", dynWord, strings.Join(dynValues, " ")) } } // Remove the '?' from the line and step the cursor back one position. newLine := append(append([]rune{}, line[:pos-1]...), line[pos:]...) return newLine, pos - 1, true }