diff options
Diffstat (limited to 'internal/shell')
| -rw-r--r-- | internal/shell/shell.go | 124 | ||||
| -rw-r--r-- | internal/shell/shell_internal_test.go | 54 |
2 files changed, 168 insertions, 10 deletions
diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 16c6366..195d1ab 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -5,6 +5,7 @@ package shell import ( + "bufio" "context" "fmt" "io" @@ -20,6 +21,74 @@ type Shell struct { rl *readline.Instance } +func shellPrompt() string { + if os.Getenv("NO_COLOR") != "" { + return "% " + } + // Bright cyan prompt marker for better visibility. + return "\x1b[1;96m%\x1b[0m " +} + +// viInputFilter provides a small vi-style modal key layer for readline. +// It is used as a reliability fallback because VimMode handling can vary +// by terminal; this keeps navigation deterministic. +type viInputFilter struct { + normalMode bool +} + +func newVIInputFilter() *viInputFilter { + // Start in insert mode to keep command entry ergonomic. + return &viInputFilter{normalMode: false} +} + +// filter maps typed runes into readline control runes. +// Returns (mappedRune, true) to pass into readline, or (_, false) to swallow. +func (v *viInputFilter) filter(r rune) (rune, bool) { + switch r { + case readline.CharEnter, readline.CharCtrlJ: + // Enter submits the line and returns to insert mode for next prompt. + v.normalMode = false + return r, true + case readline.CharEsc, 29: // Esc or Ctrl-] + v.normalMode = true + return 0, false + } + + if !v.normalMode { + return r, true + } + + switch r { + case 'i': + v.normalMode = false + return 0, false + case 'a': + v.normalMode = false + return readline.CharForward, true + case 'h': + return readline.CharBackward, true + case 'j': + return readline.CharNext, true + case 'k': + return readline.CharPrev, true + case 'l': + return readline.CharForward, true + case 'w', 'W': + return readline.MetaForward, true + case 'b', 'B': + return readline.MetaBackward, true + case '0', '^': + return readline.CharLineStart, true + case '$': + return readline.CharLineEnd, true + case 'x': + return readline.MetaDeleteKey, true + default: + // In normal mode, unknown keys should not insert text. + return readline.CharBell, true + } +} + // prefixCompleter implements readline.AutoCompleter by delegating to a // caller-supplied function that returns completions for a given prefix. // This mirrors the Ruby implementation's Readline.completion_proc. @@ -63,11 +132,15 @@ func (p *prefixCompleter) Do(line []rune, pos int) (newLine [][]rune, length int // - tab completion via completionFn // - manual history saving so we can deduplicate entries ourselves func New(completionFn func(prefix string) []string) (*Shell, error) { + viFilter := newVIInputFilter() cfg := &readline.Config{ - Prompt: "% ", - VimMode: true, + Prompt: shellPrompt(), + VimMode: false, HistoryLimit: 500, AutoComplete: &prefixCompleter{fn: completionFn}, + FuncFilterInputRune: func(r rune) (rune, bool) { + return viFilter.filter(r) + }, // Disable automatic history saving so ReadLine can deduplicate // entries before committing them, matching the Ruby behaviour: // Readline::HISTORY.pop if argv.empty? || @@ -141,17 +214,48 @@ func (s *Shell) ReadPassword(prompt string) (string, error) { return string(bytes), nil } -// ReadPassword prints prompt then reads a password from the terminal without -// echoing characters. It uses golang.org/x/term for reliable cross-platform -// masked input, bypassing the readline library which does not always display -// the prompt correctly before the process is fully interactive. +// ReadPassword prints prompt then reads a password from the terminal using +// readline in Vim mode with masked visual feedback ("*"). +// +// For non-interactive input (stdin is not a terminal), it falls back to +// reading a single line from stdin. func ReadPassword(prompt string) (string, error) { - fmt.Print(prompt) - defer fmt.Println() // move to next line after the user presses Enter + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + fmt.Print(prompt) + defer fmt.Println() // move to next line after input is complete + + r := bufio.NewReader(os.Stdin) + line, err := r.ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil + } + + viFilter := newVIInputFilter() + rl, err := readline.NewFromConfig(&readline.Config{ + FuncFilterInputRune: func(r rune) (rune, bool) { + return viFilter.filter(r) + }, + Prompt: prompt, + VimMode: false, + EnableMask: true, + MaskRune: '*', + HistoryFile: "", + DisableAutoSaveHistory: true, + }) + if err != nil { + return "", err + } + defer rl.Close() - b, err := term.ReadPassword(int(os.Stdin.Fd())) + line, err := rl.Readline() if err != nil { + if err == readline.ErrInterrupt { + return "", fmt.Errorf("interrupted") + } return "", err } - return string(b), nil + return strings.TrimRight(line, "\r\n"), nil } diff --git a/internal/shell/shell_internal_test.go b/internal/shell/shell_internal_test.go index 73284d7..16cda0a 100644 --- a/internal/shell/shell_internal_test.go +++ b/internal/shell/shell_internal_test.go @@ -5,6 +5,8 @@ package shell import ( "strings" "testing" + + "github.com/ergochat/readline" ) // TestPrefixCompleterDo exercises the Do method of prefixCompleter against a @@ -96,6 +98,58 @@ func TestPrefixCompleterDo(t *testing.T) { } } +// TestVIInputFilter verifies vi-style modal key translation used by shell and +// PIN prompts through FuncFilterInputRune. +func TestVIInputFilter(t *testing.T) { + v := newVIInputFilter() + + // Insert mode passes regular typing through unchanged. + if r, ok := v.filter('x'); !ok || r != 'x' { + t.Fatalf("insert mode passthrough got (%q,%v), want ('x',true)", r, ok) + } + + // Esc enters normal mode and is swallowed. + if r, ok := v.filter(readline.CharEsc); ok || r != 0 { + t.Fatalf("esc got (%q,%v), want (0,false)", r, ok) + } + + // Normal-mode movement mappings. + cases := []struct { + in rune + want rune + }{ + {'h', readline.CharBackward}, + {'j', readline.CharNext}, + {'k', readline.CharPrev}, + {'l', readline.CharForward}, + {'w', readline.MetaForward}, + {'b', readline.MetaBackward}, + {'0', readline.CharLineStart}, + {'$', readline.CharLineEnd}, + } + for _, tc := range cases { + if r, ok := v.filter(tc.in); !ok || r != tc.want { + t.Fatalf("normal mapping %q got (%q,%v), want (%q,true)", tc.in, r, ok, tc.want) + } + } + + // "i" returns to insert mode and is swallowed. + if r, ok := v.filter('i'); ok || r != 0 { + t.Fatalf("i got (%q,%v), want (0,false)", r, ok) + } + if r, ok := v.filter('z'); !ok || r != 'z' { + t.Fatalf("insert mode after i got (%q,%v), want ('z',true)", r, ok) + } + + // Ctrl-] should also enter normal mode. + if r, ok := v.filter(29); ok || r != 0 { + t.Fatalf("ctrl-] got (%q,%v), want (0,false)", r, ok) + } + if r, ok := v.filter('h'); !ok || r != readline.CharBackward { + t.Fatalf("normal mode after ctrl-] got (%q,%v), want (CharBackward,true)", r, ok) + } +} + // toStrings converts a [][]rune to []string for readable error output. func toStrings(runes [][]rune) []string { out := make([]string, len(runes)) |
