diff --git a/cmd/openai/image_picker_completion_test.go b/cmd/openai/image_picker_completion_test.go new file mode 100644 index 00000000..a24ab90b --- /dev/null +++ b/cmd/openai/image_picker_completion_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" +) + +func TestMainPickerCompletion(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + home := t.TempDir() + env := []string{"HOME=" + home, "USERPROFILE=" + home, "XDG_CONFIG_HOME=" + home, "OPENAI_BASE_URL=" + server.URL} + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + t.Run(shell, func(t *testing.T) { + standard := runMainDispatchWithEnv(t, shell, env, "openai", "@completion", shell) + if standard.code != 0 || standard.stderr != "" || standard.stdout == "" || strings.Contains(standard.stdout, "picker_disable") { + t.Fatalf("standard completion = %+v", standard) + } + disabled := runMainDispatchWithEnv(t, shell, env, "openai", "@completion", shell, "--picker=false") + if disabled != standard { + t.Fatalf("explicit false changed ordinary completion: %+v", disabled) + } + for _, format := range []string{"text", "json"} { + got := runMainDispatchWithEnv(t, shell, env, "openai", "--format-error", format, "@completion", shell, "--picker") + if shell == "pwsh" { + if got.code != 1 || got.stdout != "" { + t.Fatalf("unsupported hook = %+v", got) + } + message := got.stderr + if format == "json" { + payload := decodeMainStructuredError(t, format, got.stderr) + message, _ = payload["message"].(string) + } + if !strings.Contains(message, "press Enter") { + t.Fatalf("missing Enter fallback: %q", message) + } + } else if got.code != 0 || got.stderr != "" || !strings.HasPrefix(got.stdout, standard.stdout+"\n") || !strings.Contains(got.stdout, "openai_picker_disable") { + t.Fatalf("picker completion = %+v", got) + } + } + }) + } + if requests.Load() != 0 { + t.Fatalf("completion made %d API requests", requests.Load()) + } + entries, err := os.ReadDir(home) + if err != nil || len(entries) != 0 { + t.Fatalf("completion wrote to the home/config directory: %v, %v", entries, err) + } +} diff --git a/docs/image-generation-saving.md b/docs/image-generation-saving.md index d65aded3..274ddaeb 100644 --- a/docs/image-generation-saving.md +++ b/docs/image-generation-saving.md @@ -18,6 +18,9 @@ requesting an image, and Ctrl+C exits. Command previews and Ctrl+P support Bash, zsh, fish and PowerShell 7. In other or unidentified shells, generation remains available, but command printing is disabled with an explanation in the picker. +An optional [Tab shortcut](image-picker-shortcuts.md) opens the same picker in +Bash 4.3+, zsh and fish. Other shells use Enter. + After a successful generation, the picker reopens below the saved result with the active prompt and settings. Choices last until this invocation exits. Explicit image flags, output formats, piped input and redirected output retain diff --git a/docs/image-picker-shortcuts.md b/docs/image-picker-shortcuts.md new file mode 100644 index 00000000..2f470724 --- /dev/null +++ b/docs/image-picker-shortcuts.md @@ -0,0 +1,35 @@ +# Image picker shortcuts + +`openai images generate` opens the image picker when you press Enter in an +interactive terminal. To also open it with Tab, load the optional hook in your +current shell session: + +```bash +# Bash 4.3 or newer +source <(openai @completion bash --picker) +``` + +```zsh +# zsh, after completion has been initialized +autoload -Uz compinit && compinit +source <(openai @completion zsh --picker) +``` + +```fish +# fish +openai @completion fish --picker | source +``` + +Type `openai images generate` and press Tab at the end of the line. Ctrl+C +leaves the picker and keeps the command editable. Other command lines retain +ordinary completion. The shortcut requires `openai` on PATH as an executable; +aliases, shell functions, extra arguments, redirections and compound commands +use ordinary completion. Existing custom Bash Tab bindings are preserved. + +Run `openai_picker_disable` to turn off the hook in this session. Closing the +shell also removes it. These commands do not edit startup files. Ordinary +completion scripts are still available without `--picker`. + +PowerShell and Bash older than 4.3 keep normal Tab completion. Press Enter after +`openai images generate` to open the picker. Hooks are inactive outside an +interactive terminal or when `TERM=dumb`. diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go index 2cd852e0..5f2ddf5f 100644 --- a/internal/autocomplete/autocomplete.go +++ b/internal/autocomplete/autocomplete.go @@ -66,6 +66,13 @@ func OutputCompletionScript(ctx context.Context, cmd *cli.Command) error { if err != nil { return cli.Exit(err, 1) } + if cmd.Bool("picker") { + picker, err := renderPickerCompletion(s, cmd.Root().Name) + if err != nil { + return cli.Exit(err, 1) + } + completionScript += "\n" + picker + } _, err = cmd.Writer.Write([]byte(completionScript)) if err != nil { diff --git a/internal/autocomplete/picker_bash_zsh_test.go b/internal/autocomplete/picker_bash_zsh_test.go new file mode 100644 index 00000000..eee2050e --- /dev/null +++ b/internal/autocomplete/picker_bash_zsh_test.go @@ -0,0 +1,615 @@ +package autocomplete + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +// These tests run real line editors. The synthetic executable records the +// argv/terminal boundary; the picker process suite exercises the real UI. +func TestPickerBashZshTabAndLifecycle(t *testing.T) { + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + requirePickerBash(t, shell) + directory := t.TempDir() + setup := "" + if shell == "zsh" { + setup = ` +fallback_emacs() { print -r -- fallback-emacs >>"$PICKER_TEST_RESULT"; } +fallback_viins() { print -r -- fallback-viins >>"$PICKER_TEST_RESULT"; } +zle -N fallback_emacs +zle -N fallback_viins +bindkey -M emacs '^I' fallback_emacs +bindkey -M viins '^I' fallback_viins +bindkey -e +` + } + body := ` +send -- "openai images generate\t" +expect -exact "PICKER_LAUNCHED" +send -- "cancel\n" +expect -exact "PICKER_CANCELED" +expect -exact "PICKER_TEST> " +# The same command line remains editable after cancellation. Ctrl+U discards it. +send -- "\025printf 'AFTER_CANCEL\\n'\r" +expect -exact "AFTER_CANCEL\r\n" +expect -exact "PICKER_TEST> " +send -- "openai images gen\t" +` + if shell == "bash" { + body += ` +expect -exact "generate " +send -- "\025openai_picker_disable; complete -p openai >\"\$PICKER_TEST_BINDING\"\r" +` + } else { + body += ` +send -- "\025bindkey -v\r" +expect -exact "PICKER_TEST> " +send -- "openai images generate\t" +expect -exact "PICKER_LAUNCHED" +send -- "cancel\n" +expect -exact "PICKER_CANCELED" +expect -exact "PICKER_TEST> " +send -- "\025openai images gen\t" +send -- "\025openai_picker_disable; bindkey -M emacs '^I' >\"\$PICKER_TEST_BINDING\"; bindkey -M viins '^I' >>\"\$PICKER_TEST_BINDING\"\r" +` + } + body += ` +expect -exact "PICKER_TEST> " +send -- "exit\r" +expect eof +` + runPickerShellPTY(t, shell, directory, setup, "", body) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + text := string(result) + require.Contains(t, text, "launch "+shell+" 2\n\n\ntty:111\n") + binding, err := os.ReadFile(filepath.Join(directory, "binding")) + require.NoError(t, err) + if shell == "bash" { + require.Equal(t, 1, strings.Count(text, "launch ")) + require.Equal(t, "complete -o filenames -F __openai_bash_autocomplete openai\n", string(binding)) + } else { + require.Equal(t, 2, strings.Count(text, "launch ")) + require.Contains(t, text, "fallback-emacs\n") + require.Contains(t, text, "fallback-viins\n") + require.Equal(t, "\"^I\" fallback_emacs\n\"^I\" fallback_viins\n", string(binding)) + } + }) + } +} + +func TestPickerBashZshExactLines(t *testing.T) { + lines := []struct { + line string + want string + }{ + {"openai images generate", "yes"}, + {" \topenai\timages generate \t", "yes"}, + {"OPENAI IMAGES GENERATE", "no"}, + {"openai images generate --help", "no"}, + {"openai images generate >out", "no"}, + {"openai images generate;", "no"}, + {"openai images generate | cat", "no"}, + {"openai images generate && true", "no"}, + {"openai images generate\n", "no"}, + {"openai\nimages generate", "no"}, + {"'openai' images generate", "no"}, + {"command openai images generate", "no"}, + {"openai images generat", "no"}, + {"openai images generate $(touch unsafe)", "no"}, + {"openai\rimages generate", "no"}, + } + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + requirePickerBash(t, shell) + var probe strings.Builder + if shell == "bash" { + probe.WriteString("shopt -s nocasematch\n") + } else { + probe.WriteString("setopt nocasematch\n") + } + var expected strings.Builder + for _, line := range lines { + variable, cursor := "COMP_LINE", "COMP_POINT" + if shell == "zsh" { + variable, cursor = "BUFFER", "CURSOR" + } + fmt.Fprintf(&probe, "%s=%s; %s=${#%s}\n", variable, pickerShellQuote(line.line), cursor, variable) + probe.WriteString("if __openai_picker_matches; then printf 'yes\\n'; else printf 'no\\n'; fi >>\"$PICKER_TEST_RESULT\"\n") + expected.WriteString(line.want + "\n") + } + probe.WriteString("COMP_LINE='openai images generate'; COMP_POINT=5; BUFFER=$COMP_LINE; CURSOR=5\n") + probe.WriteString("if __openai_picker_matches; then printf 'yes\\n'; else printf 'no\\n'; fi >>\"$PICKER_TEST_RESULT\"\n") + expected.WriteString("no\n") + directory := t.TempDir() + runPickerShellPTY(t, shell, directory, "", probe.String(), `send -- "exit\r"; expect eof`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, expected.String(), string(result)) + }) + } +} + +func TestPickerZshHintAndReplacement(t *testing.T) { + directory := t.TempDir() + probe := ` +BUFFER='openai images generate'; CURSOR=${#BUFFER}; POSTDISPLAY='existing suggestion' +__openai_picker_hint +printf '%s\n' "$POSTDISPLAY" >>"$PICKER_TEST_RESULT" +POSTDISPLAY=''; __openai_picker_hint +printf '%s\n' "$POSTDISPLAY" >>"$PICKER_TEST_RESULT" +BUFFER='openai images generate --help'; __openai_picker_hint +printf '<%s>\n' "$POSTDISPLAY" >>"$PICKER_TEST_RESULT" +POSTDISPLAY='new suggestion'; __openai_picker_hint +printf '%s\n' "$POSTDISPLAY" >>"$PICKER_TEST_RESULT" +new_tab() { :; }; zle -N new_tab +bindkey -M emacs '^I' new_tab +source "$PICKER_TEST_HOOK" +openai_picker_disable +bindkey -M emacs '^I' >>"$PICKER_TEST_RESULT" +openai_picker_disable +bindkey -M emacs '^I' >>"$PICKER_TEST_RESULT" +` + runPickerShellPTY(t, "zsh", directory, "", probe, `send -- "exit\r"; expect eof`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, "existing suggestion\n [Tab: image options]\n<>\nnew suggestion\n\"^I\" new_tab\n\"^I\" new_tab\n", string(result)) +} + +func TestPickerBashReplacementAndDisable(t *testing.T) { + requirePickerBash(t, "bash") + directory := t.TempDir() + probe := ` +complete -F newer_completion openai +openai_picker_disable +complete -p openai >>"$PICKER_TEST_RESULT" +openai_picker_disable +complete -p openai >>"$PICKER_TEST_RESULT" +` + runPickerShellPTY(t, "bash", directory, "", probe, `send -- "exit\r"; expect eof`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, strings.Repeat("complete -F newer_completion openai\n", 2), string(result)) +} + +func TestPickerBashPreservesOtherCompletionKey(t *testing.T) { + requirePickerBash(t, "bash") + for _, mode := range []string{"emacs", "vi"} { + t.Run(mode, func(t *testing.T) { + directory := t.TempDir() + setup := "set -o " + mode + ` +other_completion() { + printf '%s %s\n' "$COMP_KEY" "$COMP_TYPE" >>"$PICKER_TEST_RESULT" + COMPREPLY=() + if [[ $COMP_KEY == 9 ]]; then COMPREPLY=(tab-result); fi +} +complete -F other_completion other +` + runPickerShellPTY(t, "bash", directory, setup, "", ` +send -- "other tab\t" +expect -exact "tab-result " +send -- "\025exit\r"; expect eof +`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, "9 9\n", string(result)) + }) + } +} + +func TestPickerBashReenableAfterTemporaryReplacement(t *testing.T) { + requirePickerBash(t, "bash") + for _, mode := range []string{"emacs", "vi"} { + t.Run(mode, func(t *testing.T) { + directory := t.TempDir() + probe := ` +for keymap in emacs-standard vi-insert; do bind -m "$keymap" '"\C-i": "temporary"'; done +openai_picker_disable +for keymap in emacs-standard vi-insert; do + __openai_picker_binding "$keymap" '\C-i' >>"$PICKER_TEST_BINDING" + bind -m "$keymap" '"\C-i": complete' +done +source "$PICKER_TEST_HOOK" +` + runPickerShellPTY(t, "bash", directory, "set -o "+mode, probe, ` +send -- "openai images generate\t" +expect -exact "PICKER_LAUNCHED" +send -- "cancel\n" +expect -exact "PICKER_CANCELED" +expect -exact "PICKER_TEST> " +send -- "\025openai images gen\t" +expect -exact "generate " +send -- "\025openai_picker_disable\r" +expect -exact "PICKER_TEST> " +send -- "exit\r"; expect eof +`) + binding, err := os.ReadFile(filepath.Join(directory, "binding")) + require.NoError(t, err) + require.Equal(t, strings.Repeat("\"\\C-i\": \"temporary\"\n", 2), string(binding)) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(string(result), "launch ")) + }) + } +} + +func TestPickerBashBindingsAndResourcing(t *testing.T) { + requirePickerBash(t, "bash") + for _, scenario := range []struct { + name, setup, probe, want string + }{ + { + name: "custom macro", + setup: `bind '"\C-i": "custom"'`, + probe: `source "$PICKER_TEST_HOOK"; openai_picker_disable +__openai_picker_binding emacs-standard '\C-i' >>"$PICKER_TEST_RESULT"`, + want: "\"\\C-i\": \"custom\"\n", + }, + { + name: "custom shell callback", + setup: `bind -x '"\C-i": printf custom'`, + probe: `source "$PICKER_TEST_HOOK"; openai_picker_disable +__openai_picker_binding emacs-standard '\C-i' >>"$PICKER_TEST_RESULT"`, + want: "\"\\C-i\": \"printf custom\"\n", + }, + { + name: "occupied private key", + setup: `bind -x '"\e[99;1\C-i": printf owned'`, + probe: `__openai_picker_binding emacs-standard '\C-i' >>"$PICKER_TEST_RESULT" +openai_picker_disable +__openai_picker_binding emacs-standard '\e[99;1\C-i' >>"$PICKER_TEST_RESULT"`, + want: "\"\\C-i\": complete\n\"\\e[99;1\\C-i\": \"printf owned\"\n", + }, + { + name: "replacement after install", + probe: `bind '"\C-i": "new owner"' +source "$PICKER_TEST_HOOK"; openai_picker_disable +__openai_picker_binding emacs-standard '\C-i' >>"$PICKER_TEST_RESULT"`, + want: "\"\\C-i\": \"new owner\"\n", + }, + { + name: "replacement private callback", + probe: `bind -x '"\e[99;2~": printf newer' +COMP_LINE='openai images generate'; COMP_POINT=${#COMP_LINE}; COMP_TYPE=9; COMP_KEY=9 +COMP_WORDS=(openai images generate); COMP_CWORD=2 +__openai_picker_complete +openai_picker_disable +__openai_picker_binding emacs-standard '\e[99;2~' >>"$PICKER_TEST_RESULT"`, + want: "\"\\e[99;2~\": \"printf newer\"\n", + }, + { + name: "repeated install disable enable", + probe: `source "$PICKER_TEST_HOOK"; openai_picker_disable +__openai_picker_binding emacs-standard '\C-i' >>"$PICKER_TEST_RESULT" +__openai_picker_binding emacs-standard '\e[99;1\C-i' >>"$PICKER_TEST_RESULT" +__openai_picker_binding emacs-standard '\e[99;2~' >>"$PICKER_TEST_RESULT" +source "$PICKER_TEST_HOOK"; openai_picker_disable +__openai_picker_binding emacs-standard '\C-i' >>"$PICKER_TEST_RESULT"`, + want: strings.Repeat("\"\\C-i\": complete\n", 2), + }, + } { + t.Run(scenario.name, func(t *testing.T) { + directory := t.TempDir() + runPickerShellPTY(t, "bash", directory, scenario.setup, scenario.probe, `send -- "exit\r"; expect eof`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, scenario.want, string(result)) + }) + } +} + +func TestPickerBashRejectsWholeLineContext(t *testing.T) { + requirePickerBash(t, "bash") + var interaction strings.Builder + for index, line := range []string{ + "OPENAI_KEY=x openai images generate", + "true; openai images generate", + "true && openai images generate", + "true | openai images generate", + "(openai images generate", + } { + fmt.Fprintf(&interaction, "send -- {%s}\nsend -- \"\\t\\025printf 'CONTEXT_%d\\\\n'\\r\"\n", line, index) + fmt.Fprintf(&interaction, "expect -exact \"CONTEXT_%d\\r\\n\"\nexpect -exact \"PICKER_TEST> \"\n", index) + } + interaction.WriteString(`send -- "exit\r"; expect eof`) + directory := t.TempDir() + runPickerShellPTY(t, "bash", directory, "", "", interaction.String()) + _, err := os.Stat(filepath.Join(directory, "result")) + require.ErrorIs(t, err, os.ErrNotExist, "none of the completion attempts may run the picker") +} + +func TestPickerBashKeepsRepeatedCompletionAfterCancel(t *testing.T) { + requirePickerBash(t, "bash") + directory := t.TempDir() + runPickerShellPTY(t, "bash", directory, "", "", ` +send -- "openai images generate\t" +expect -exact "PICKER_LAUNCHED" +send -- "cancel\n" +expect -exact "PICKER_CANCELED" +expect -exact "PICKER_TEST> " +send -- "\025__openai_bash_autocomplete() { COMPREPLY=(general generate); }\r" +expect -exact "PICKER_TEST> " +send -- "openai gen\t\t\t" +expect -re {general +generate} +send -- "\025exit\r"; expect eof +`) +} + +func TestPickerBashPreservesWhitespaceAfterCancel(t *testing.T) { + requirePickerBash(t, "bash") + for _, line := range []string{" openai images generate", " openai\timages generate ", "openai images generate\t"} { + t.Run(fmt.Sprintf("%q", line), func(t *testing.T) { + directory := t.TempDir() + probe := ` +capture_buffer() { printf 'buffer:<%s>\n' "$READLINE_LINE" >>"$PICKER_TEST_RESULT"; } +bind -x '"\C-x\C-g": capture_buffer' +` + interaction := fmt.Sprintf("send -- \"%s\\t\"\n", strings.ReplaceAll(line, "\t", `\026\t`)) + ` +expect -exact "PICKER_LAUNCHED" +send -- "cancel\n" +expect -exact "PICKER_CANCELED" +expect -exact "PICKER_TEST> " +send -- "\030\007\025exit\r"; expect eof +` + runPickerShellPTY(t, "bash", directory, "", probe, interaction) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Contains(t, string(result), "buffer:<"+line+">\n") + }) + } +} + +func TestPickerBashLegacyKeepsNormalCompletion(t *testing.T) { + if _, err := os.Stat("/bin/bash"); err != nil { + t.Skip("system Bash is unavailable") + } + if err := exec.Command("/bin/bash", "-c", `(( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) ))`).Run(); err != nil { + t.Skip("system Bash is new enough for the Tab hook") + } + t.Setenv("OPENAI_CLI_TEST_BASH", "/bin/bash") + directory := t.TempDir() + probe := ` +if type openai_picker_disable >/dev/null 2>&1; then printf 'installed\n'; else printf 'preserved\n'; fi >>"$PICKER_TEST_RESULT" +complete -p openai >>"$PICKER_TEST_RESULT" +` + runPickerShellPTY(t, "bash", directory, "", probe, ` +send -- "openai images gen\t" +expect -exact "generate " +send -- "\025exit\r"; expect eof +`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, "preserved\ncomplete -o filenames -F __openai_bash_autocomplete openai\n", string(result)) +} + +func TestPickerBashZshRejectAliasesAndFunctions(t *testing.T) { + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + requirePickerBash(t, shell) + directory := t.TempDir() + probe := ` +COMP_LINE='openai images generate'; COMP_POINT=${#COMP_LINE}; BUFFER=$COMP_LINE; CURSOR=${#BUFFER} +alias openai='printf alias' +if __openai_picker_matches; then printf 'launched\n'; else printf 'preserved\n'; fi >>"$PICKER_TEST_RESULT" +unalias openai +openai() { :; } +if __openai_picker_matches; then printf 'launched\n'; else printf 'preserved\n'; fi >>"$PICKER_TEST_RESULT" +` + runPickerShellPTY(t, shell, directory, "", probe, `send -- "exit\r"; expect eof`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, "preserved\npreserved\n", string(result)) + }) + } +} + +func TestPickerZshChainedReplacementAfterReenable(t *testing.T) { + directory := t.TempDir() + setup := ` +old_tab() { print -r -- old >>"$PICKER_TEST_RESULT"; } +zle -N old_tab +bindkey -M emacs '^I' old_tab +bindkey -e +` + probe := ` +new_tab() { print -r -- new >>"$PICKER_TEST_RESULT"; zle __openai_picker_tab_emacs; } +zle -N new_tab +bindkey -M emacs '^I' new_tab +openai_picker_disable +source "$PICKER_TEST_HOOK" +` + runPickerShellPTY(t, "zsh", directory, setup, probe, ` +send -- "unrelated\t" +send -- "\025printf 'CHAINED_DONE\\n'\r" +expect -exact "CHAINED_DONE\r\n" +expect -exact "PICKER_TEST> " +send -- "exit\r"; expect eof +`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, "new\nold\n", string(result)) +} + +func TestPickerBashZshDumbTerminalKeepsCompletion(t *testing.T) { + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + directory := t.TempDir() + probe := ` +if type openai_picker_disable >/dev/null 2>&1; then printf 'installed\n'; else printf 'preserved\n'; fi >>"$PICKER_TEST_RESULT" +` + runPickerShellPTY(t, shell, directory, "TERM=dumb", probe, `send -- "exit\r"; expect eof`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, "preserved\n", string(result)) + }) + } +} + +func TestPickerBashZshDoesNotInstallOutsideTerminal(t *testing.T) { + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + binary, err := exec.LookPath(shell) + if err != nil { + t.Skip(shell + " is not available") + } + script, err := renderPickerCompletion(CompletionStyle(shell), "openai") + require.NoError(t, err) + cmd := exec.Command(binary, "-c", script+"\nif type openai_picker_disable >/dev/null 2>&1; then exit 1; fi") + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + }) + } +} + +func TestBashCompletionWithoutMapfile(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is not available") + } + script, err := shellCompletions[CompletionStyleBash](&cli.Command{}, "openai") + require.NoError(t, err) + for _, scenario := range []string{"values", "files", "empty"} { + t.Run(scenario, func(t *testing.T) { + directory := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(directory, "fixture one.txt"), nil, 0o600)) + probe := ` +mapfile() { printf 'unexpected mapfile call' >&2; return 99; } +openai() { if [ "$PICKER_COMPLETION_CASE" = files ]; then return 10; fi; if [ "$PICKER_COMPLETION_CASE" != empty ]; then printf 'fixture one\nfixture two\n'; fi; } +` + script + ` +file='caller shell value' +COMP_WORDS=(openai fix); COMP_CWORD=1 +__openai_bash_autocomplete +if [[ "$file" != 'caller shell value' ]]; then + printf 'completion changed caller variable file to <%s>\n' "$file" >&2 + exit 1 +fi +for candidate in "${COMPREPLY[@]}"; do printf '<%s>\n' "$candidate"; done +` + cmd := exec.Command(bash, "-c", probe) + cmd.Dir = directory + cmd.Env = append(os.Environ(), "PICKER_COMPLETION_CASE="+scenario) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + want := "\n\n" + if scenario == "files" { + want = "\n" + } else if scenario == "empty" { + want = "" + } + require.Equal(t, want, string(out)) + }) + } +} + +func pickerShellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func runPickerShellPTY(t *testing.T, shell, directory, setup, probe, interaction string) { + t.Helper() + binary, err := exec.LookPath(pickerTestShell(shell)) + if err != nil { + t.Skip(shell + " is not available") + } + expect, err := exec.LookPath("expect") + if err != nil { + t.Skip("expect is required for real shell line editor tests") + } + completion, err := shellCompletions[CompletionStyle(shell)](&cli.Command{}, "openai") + require.NoError(t, err) + hook, err := renderPickerCompletion(CompletionStyle(shell), "openai") + require.NoError(t, err) + if shell == "zsh" { + completion = "autoload -Uz compinit; compinit -D\n" + completion + } + require.NoError(t, os.WriteFile(filepath.Join(directory, "hook"), []byte(hook), 0o600)) + startup := "PS1='PICKER_TEST> '; PS2='PICKER_MORE> '\n" + completion + "\n" + setup + "\n" + hook + "\n" + probe + "\nprintf '\\nPICKER_SHELL_READY\\n'\n" + if shell == "fish" { + startup = "function fish_prompt; printf 'PICKER_TEST> '; end\n" + completion + "\n" + setup + "\n" + hook + "\n" + probe + "\nprintf '\\nPICKER_SHELL_READY\\n'\n" + } + require.NoError(t, os.WriteFile(filepath.Join(directory, "startup"), []byte(startup), 0o600)) + fixture := `#!/bin/sh +if [ "$1" = __complete ]; then + for completion_word do :; done + case "$completion_word" in gen*) printf 'generate\n';; esac + exit 0 +fi +printf 'launch %s %s\n' "$OPENAI_PICKER_SHELL" "$#" >>"$PICKER_TEST_RESULT" +printf '<%s>\n' "$@" >>"$PICKER_TEST_RESULT" +terminal_fds='' +for fd in 0 1 2; do if [ -t "$fd" ]; then terminal_fds="${terminal_fds}1"; else terminal_fds="${terminal_fds}0"; fi; done +printf 'tty:%s\n' "$terminal_fds" >>"$PICKER_TEST_RESULT" +printf '\nPICKER_LAUNCHED\n' +read -r response +printf '\nPICKER_CANCELED\n' +exit 130 +` + require.NoError(t, os.WriteFile(filepath.Join(directory, "openai"), []byte(fixture), 0o700)) + arguments := "--noprofile --norc -i" + if shell == "zsh" { + arguments = "-f -i" + } + if shell == "fish" { + arguments = "--no-config --interactive" + } + driver := ` +set timeout 8 +match_max 100000 +proc fail {message} { puts stderr $message; exit 1 } +spawn -noecho $env(PICKER_TEST_SHELL) ` + arguments + ` +expect_before { + -exact "\033\1330c" {send -- "\033\133?1;2c"; exp_continue} + -exact "\033\1336n" {send -- "\033\1331;1R"; exp_continue} +} +expect_after timeout { fail "shell probe timed out" } +send -- "source \"\$PICKER_TEST_STARTUP\"\r" +expect -exact "\r\nPICKER_SHELL_READY\r\n" +expect -exact "PICKER_TEST> " +` + interaction + require.NoError(t, os.WriteFile(filepath.Join(directory, "driver.exp"), []byte(driver), 0o600)) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, expect, filepath.Join(directory, "driver.exp")) + cmd.Dir = directory + cmd.Env = []string{ + "HOME=" + directory, "PATH=" + directory + ":" + os.Getenv("PATH"), "LC_ALL=C", "TERM=xterm-256color", + "BASH_SILENCE_DEPRECATION_WARNING=1", "PICKER_TEST_SHELL=" + binary, + "PICKER_TEST_STARTUP=" + filepath.Join(directory, "startup"), + "PICKER_TEST_HOOK=" + filepath.Join(directory, "hook"), + "PICKER_TEST_RESULT=" + filepath.Join(directory, "result"), + "PICKER_TEST_BINDING=" + filepath.Join(directory, "binding"), + } + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) +} + +func pickerTestShell(shell string) string { + if shell == "bash" && os.Getenv("OPENAI_CLI_TEST_BASH") != "" { + return os.Getenv("OPENAI_CLI_TEST_BASH") + } + return shell +} + +func requirePickerBash(t *testing.T, shell string) { + t.Helper() + if shell != "bash" { + return + } + binary, err := exec.LookPath(pickerTestShell(shell)) + if err != nil { + t.Skip("bash is not available") + } + if err := exec.Command(binary, "-c", `(( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3) ))`).Run(); err != nil { + t.Skip("Tab hook requires Bash 4.3+; set OPENAI_CLI_TEST_BASH to test a supported runtime") + } +} diff --git a/internal/autocomplete/picker_fish_test.go b/internal/autocomplete/picker_fish_test.go new file mode 100644 index 00000000..477408aa --- /dev/null +++ b/internal/autocomplete/picker_fish_test.go @@ -0,0 +1,106 @@ +package autocomplete + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPickerFishTabAndLifecycle(t *testing.T) { + directory := t.TempDir() + runPickerShellPTY(t, "fish", directory, "", "", ` +send -- "openai images generate\t" +expect -exact "PICKER_LAUNCHED" +send -- "cancel\n" +expect -exact "PICKER_CANCELED" +expect -exact "PICKER_TEST> " +send -- "\025printf 'AFTER_CANCEL\\n'\r" +expect -exact "AFTER_CANCEL\r\n" +expect -exact "PICKER_TEST> " +send -- "openai images gen\t" +expect -exact "generate" +send -- "\025openai_picker_disable; openai_picker_disable; bind --user \\t >\"\$PICKER_TEST_BINDING\" 2>/dev/null\r" +expect -exact "PICKER_TEST> " +send -- "exit\r"; expect eof +`) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, "launch fish 2\n\n\ntty:111\n", string(result)) + binding, err := os.ReadFile(filepath.Join(directory, "binding")) + require.NoError(t, err) + require.Empty(t, binding) +} + +func TestPickerFishBindingsAndResourcing(t *testing.T) { + for _, scenario := range []struct{ name, setup, probe, interaction, want string }{ + { + name: "custom binding", + setup: `fish_default_key_bindings +function custom_tab; printf 'fallback\n' >>"$PICKER_TEST_RESULT"; end +bind \t custom_tab`, + probe: `source "$PICKER_TEST_HOOK"`, + interaction: `send -- "other\t\025openai_picker_disable; openai_picker_disable; bind --user \\t >>\"\$PICKER_TEST_RESULT\"\r"`, + want: "fallback\nbind tab custom_tab\n", + }, + { + name: "replacement binding", + probe: `function new_tab; end +bind \t new_tab +source "$PICKER_TEST_HOOK" +openai_picker_disable +openai_picker_disable +bind --user \t >>"$PICKER_TEST_RESULT"`, + want: "bind tab new_tab\n", + }, + { + name: "disable then enable", + probe: `openai_picker_disable +source "$PICKER_TEST_HOOK"`, + interaction: `send -- "openai images generate\t" +expect -exact "PICKER_LAUNCHED" +send -- "cancel\n" +expect -exact "PICKER_CANCELED" +expect -exact "PICKER_TEST> " +send -- "\025printf 'DONE\\n'\r"`, + want: "launch fish 2\n\n\ntty:111\n", + }, + } { + t.Run(scenario.name, func(t *testing.T) { + directory := t.TempDir() + interaction := scenario.interaction + if interaction != "" { + interaction += "\nexpect -exact \"PICKER_TEST> \"\n" + } + interaction += `send -- "exit\r"; expect eof` + runPickerShellPTY(t, "fish", directory, scenario.setup, scenario.probe, interaction) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, scenario.want, string(result)) + }) + } +} + +func TestPickerFishRejectsOtherCommandLines(t *testing.T) { + directory := t.TempDir() + setup := `fish_default_key_bindings +function custom_tab; printf 'fallback\n' >>"$PICKER_TEST_RESULT"; end +bind \t custom_tab` + var interaction strings.Builder + for _, line := range []string{ + "openai images generate --help", "openai images generate >out", "openai images generate;", + "openai images generate | cat", "true; openai images generate", "'openai' images generate", + "OPENAI IMAGES GENERATE", "openai images generate $(touch unsafe)", + } { + interaction.WriteString("send -- {" + line + "}\nsend -- \"\\t\\025printf 'NEXT\\\\n'\\r\"\nexpect -exact \"NEXT\\r\\n\"\nexpect -exact \"PICKER_TEST> \"\n") + } + interaction.WriteString(`send -- "exit\r"; expect eof`) + runPickerShellPTY(t, "fish", directory, setup, "", interaction.String()) + result, err := os.ReadFile(filepath.Join(directory, "result")) + require.NoError(t, err) + require.Equal(t, strings.Repeat("fallback\n", 8), string(result)) + _, err = os.Stat(filepath.Join(directory, "unsafe")) + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/internal/autocomplete/picker_script.go b/internal/autocomplete/picker_script.go new file mode 100644 index 00000000..d5564f40 --- /dev/null +++ b/internal/autocomplete/picker_script.go @@ -0,0 +1,38 @@ +package autocomplete + +import ( + "fmt" + "strings" +) + +// Picker hooks share the completion script distribution, but are opt-in because +// they change an interactive key's behavior. They never edit startup files. +func renderPickerCompletion(shell CompletionStyle, appName string) (string, error) { + if appName == "" { + return "", fmt.Errorf("a command name is required for picker integration") + } + for i, r := range appName { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_' || i > 0 && r >= '0' && r <= '9') { + return "", fmt.Errorf("picker integration requires a simple command name on PATH") + } + } + if shell == CompletionStylePowershell { + // PSReadLine can prefetch input before invoking a key handler. A child + // launched by that handler cannot consume it, so it can reach the shell + // after cancellation. Keep Tab in the line editor and use normal Enter. + return "", fmt.Errorf("PowerShell uses normal Tab completion. Type %s images generate and press Enter to open the image picker.", appName) + } + files := map[CompletionStyle]string{ + CompletionStyleBash: "bash_picker.bash", CompletionStyleZsh: "zsh_picker.zsh", + CompletionStyleFish: "fish_picker.fish", + } + name, ok := files[shell] + if !ok { + return "", fmt.Errorf("unsupported picker shell") + } + script, err := autoCompleteFS.ReadFile("shellscripts/" + name) + if err != nil { + return "", err + } + return strings.ReplaceAll(string(script), "__APPNAME__", appName), nil +} diff --git a/internal/autocomplete/picker_script_test.go b/internal/autocomplete/picker_script_test.go new file mode 100644 index 00000000..46308609 --- /dev/null +++ b/internal/autocomplete/picker_script_test.go @@ -0,0 +1,68 @@ +package autocomplete + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestPickerScriptRequiresSafeCommandName(t *testing.T) { + for _, name := range []string{"", "openai;echo bad", "../openai", "x'\n", "openai.exe", "$(id)", "雪", "1name"} { + script, err := renderPickerCompletion(CompletionStyleZsh, name) + require.Error(t, err, name) + require.Empty(t, script) + } + _, err := renderPickerCompletion("unknown", "openai") + require.Error(t, err) +} + +func TestPickerCompletionRequiresExplicitOptIn(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + t.Run(shell, func(t *testing.T) { + for _, enabled := range []bool{false, true} { + var output bytes.Buffer + root := &cli.Command{Name: "openai", Writer: &output, Commands: []*cli.Command{{ + Name: "@completion", Flags: []cli.Flag{&cli.BoolFlag{Name: "picker"}}, Action: OutputCompletionScript, + }}, ExitErrHandler: func(context.Context, *cli.Command, error) {}} + args := []string{"openai", "@completion", shell} + if enabled { + args = append(args, "--picker") + } + err := root.Run(context.Background(), args) + if shell == "pwsh" && enabled { + require.EqualError(t, err, "PowerShell uses normal Tab completion. Type openai images generate and press Enter to open the image picker.") + var exit cli.ExitCoder + require.ErrorAs(t, err, &exit) + require.Equal(t, 1, exit.ExitCode()) + require.Empty(t, output.String(), "unsupported setup must not emit an executable partial script") + continue + } + require.NoError(t, err) + standard, err := shellCompletions[CompletionStyle(shell)](root, "openai") + require.NoError(t, err) + if enabled { + hook, err := renderPickerCompletion(CompletionStyle(shell), "openai") + require.NoError(t, err) + require.Equal(t, standard+"\n"+hook, output.String()) + require.Contains(t, output.String(), "openai_picker_disable") + require.NotContains(t, output.String(), "__APPNAME__") + } else { + require.Equal(t, standard, output.String()) + } + } + }) + } +} + +func TestPickerScriptFailureDoesNotEmitPartialSetup(t *testing.T) { + var output bytes.Buffer + root := &cli.Command{Name: "unsafe;name", Writer: &output, Commands: []*cli.Command{{ + Name: "@completion", Flags: []cli.Flag{&cli.BoolFlag{Name: "picker"}}, Action: OutputCompletionScript, + }}, ExitErrHandler: func(context.Context, *cli.Command, error) {}} + err := root.Run(context.Background(), []string{"unsafe;name", "@completion", "bash", "--picker"}) + require.Error(t, err) + require.Empty(t, output.String()) +} diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash index 95183934..0f4504a5 100755 --- a/internal/autocomplete/shellscripts/bash_autocomplete.bash +++ b/internal/autocomplete/shellscripts/bash_autocomplete.bash @@ -2,7 +2,7 @@ ____APPNAME___bash_autocomplete() { if [[ "${COMP_WORDS[0]}" != "source" ]]; then - local cur completions exit_code + local cur completions exit_code file local IFS=$'\n' cur="${COMP_WORDS[COMP_CWORD]}" @@ -49,16 +49,27 @@ ____APPNAME___bash_autocomplete() { fi if [[ "$force_file_completion" == true ]]; then - local file COMPREPLY=() while IFS= read -r file; do COMPREPLY+=("$prefix$file") done < <(compgen -f -- "$file_part") else case $exit_code in - 10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file completion - 11) COMPREPLY=() ;; # no completion - 0) mapfile -t COMPREPLY <<<"$completions" ;; # use returned completions + 10) # File completion, including Bash 3.2 (which has no mapfile). + COMPREPLY=() + while IFS= read -r file; do + COMPREPLY+=("$file") + done < <(compgen -f -- "$cur") + ;; + 11) COMPREPLY=() ;; # no completion + 0) + COMPREPLY=() + if [[ -n "$completions" ]]; then + while IFS= read -r file; do + COMPREPLY+=("$file") + done <<<"$completions" + fi + ;; esac fi return 0 diff --git a/internal/autocomplete/shellscripts/bash_picker.bash b/internal/autocomplete/shellscripts/bash_picker.bash new file mode 100644 index 00000000..a6f8e7ff --- /dev/null +++ b/internal/autocomplete/shellscripts/bash_picker.bash @@ -0,0 +1,130 @@ +# Optional image picker integration. Source the output of +# __APPNAME__ @completion bash --picker in an interactive terminal. +if [[ $- == *i* && -t 0 && -t 1 && -t 2 && -n ${TERM-} && ${TERM-} != dumb ]]; then + if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then + : # Keep ordinary completion on older Bash without startup diagnostics. + else + ____APPNAME___picker_binding() { + local line prefix="\"$2\" " + while IFS= read -r line; do + if [ "${line%%: *}" = "\"$2\"" ]; then + printf '%s\n' "$line" + return + fi + # bind -X omits the colon used by bind -p and bind -s. + if [ "${line#"$prefix"}" != "$line" ]; then + printf '"%s": %s\n' "$2" "${line#"$prefix"}" + return + fi + done < <({ bind -m "$1" -p; bind -m "$1" -s; bind -m "$1" -X; } 2>/dev/null) + } + + ____APPNAME___picker_matches() { + local IFS=$' \t' + local -a words + [[ ${COMP_LINE-} != *$'\n'* && ${COMP_POINT-0} -eq ${#COMP_LINE} ]] || return 1 + read -r -a words <<<"$COMP_LINE" + # `test` keeps exact case even with the user's nocasematch option set. + [ ${#words[@]} -eq 3 ] && [ "${words[0]}" = '__APPNAME__' ] && + [ "${words[1]}" = images ] && [ "${words[2]}" = generate ] && + [ "$(type -t '__APPNAME__')" = file ] + } + + ____APPNAME___picker_redraw() { + local keymap=${____APPNAME___picker_active_keymap-} + if [ -n "$keymap" ] && + [ "$(____APPNAME___picker_binding "$keymap" '\e[99;2~')" = '"\e[99;2~": "____APPNAME___picker_redraw"' ]; then + bind -m "$keymap" '"\e[99;2~": ""' + fi + # COMP_LINE omits shell syntax before this command (assignments, pipes, + # and command separators). Only bind -x exposes the entire editor buffer. + local COMP_LINE=${READLINE_LINE-} COMP_POINT=${READLINE_POINT-0} + if [[ ${____APPNAME___picker_enabled-} == 1 && -t 0 && -t 1 && -t 2 && -n ${TERM-} && ${TERM-} != dumb ]] && + ____APPNAME___picker_matches; then + # Normal completion may have added a suffix. Cancel restores the line + # that requested the picker, including its original cursor position. + local leading=${READLINE_LINE%%[!$' \t']*} + local candidate=$____APPNAME___picker_candidate_line + candidate=${candidate#"${candidate%%[!$' \t']*}"} + READLINE_LINE=$leading$candidate + READLINE_POINT=${#READLINE_LINE} + printf '\n' + OPENAI_PICKER_SHELL=bash command '__APPNAME__' images generate <&2 + fi + # Returning from bind -x asks Readline to redraw its own prompt/buffer. + } + + ____APPNAME___picker_complete() { + local keymap=vi-insert + local binding + [[ -o emacs ]] && keymap=emacs-standard + if [[ ${____APPNAME___picker_enabled-} == 1 && ${COMP_TYPE-} == 9 && ${COMP_KEY-} == 9 && + -t 0 && -t 1 && -t 2 && -n ${TERM-} && ${TERM-} != dumb ]] && + [ "${____APPNAME___picker_keymaps[$keymap]-}" = 1 ] && + [ "$(____APPNAME___picker_binding "$keymap" '\C-i')" = '"\C-i": "\e[99;1\C-i\e[99;2~"' ] && + [ "$(____APPNAME___picker_binding "$keymap" '\e[99;1\C-i')" = '"\e[99;1\C-i": complete' ] && + ____APPNAME___picker_matches; then + binding=$(____APPNAME___picker_binding "$keymap" '\e[99;2~') + if [ "$binding" = '"\e[99;2~": ""' ] || [ "$binding" = '"\e[99;2~": "____APPNAME___picker_redraw"' ]; then + ____APPNAME___picker_active_keymap=$keymap + ____APPNAME___picker_candidate_line=$COMP_LINE + bind -m "$keymap" -x '"\e[99;2~": ____APPNAME___picker_redraw' + fi + fi + ____APPNAME___bash_autocomplete "$@" + } + + __APPNAME___picker_disable() { + ____APPNAME___picker_enabled=0 + local keymap binding + if [ "$(complete -p '__APPNAME__' 2>/dev/null)" = 'complete -o filenames -F ____APPNAME___picker_complete __APPNAME__' ]; then + complete -o filenames -F ____APPNAME___bash_autocomplete '__APPNAME__' + fi + for keymap in emacs-standard vi-insert; do + [ "${____APPNAME___picker_keymaps[$keymap]-}" = 1 ] || continue + if [ "$(____APPNAME___picker_binding "$keymap" '\C-i')" = '"\C-i": "\e[99;1\C-i\e[99;2~"' ]; then + bind -m "$keymap" '"\C-i": complete' + if [ "$(____APPNAME___picker_binding "$keymap" '\e[99;1\C-i')" = '"\e[99;1\C-i": complete' ]; then + bind -m "$keymap" -r '\e[99;1\C-i' + fi + binding=$(____APPNAME___picker_binding "$keymap" '\e[99;2~') + if [ "$binding" = '"\e[99;2~": ""' ] || [ "$binding" = '"\e[99;2~": "____APPNAME___picker_redraw"' ]; then + bind -m "$keymap" -r '\e[99;2~' + fi + unset '____APPNAME___picker_keymaps[$keymap]' + fi + done + } + + ____APPNAME___picker_install() { + local keymap tab completion redraw owned + declare -gA ____APPNAME___picker_keymaps + for keymap in emacs-standard vi-insert; do + tab=$(____APPNAME___picker_binding "$keymap" '\C-i') + completion=$(____APPNAME___picker_binding "$keymap" '\e[99;1\C-i') + redraw=$(____APPNAME___picker_binding "$keymap" '\e[99;2~') + owned=${____APPNAME___picker_keymaps[$keymap]-} + # A previous installation may have lost Tab to another owner. Only + # treat exact, still-owned bindings as available for reinstallation. + if [ "$owned" = 1 ]; then + [ "$tab" = '"\C-i": "\e[99;1\C-i\e[99;2~"' ] && tab='"\C-i": complete' + [ "$completion" = '"\e[99;1\C-i": complete' ] && completion='' + if [ "$redraw" = '"\e[99;2~": ""' ] || [ "$redraw" = '"\e[99;2~": "____APPNAME___picker_redraw"' ]; then + redraw='' + fi + fi + if [ "$tab" = '"\C-i": complete' ] && [ -z "$completion" ] && [ -z "$redraw" ]; then + # End the completion sequence in Tab so every command's completion + # receives the ordinary COMP_KEY=9, including unrelated commands. + bind -m "$keymap" '"\e[99;1\C-i": complete' + bind -m "$keymap" '"\e[99;2~": ""' + bind -m "$keymap" '"\C-i": "\e[99;1\C-i\e[99;2~"' + ____APPNAME___picker_keymaps[$keymap]=1 + fi + done + ____APPNAME___picker_enabled=1 + complete -o filenames -F ____APPNAME___picker_complete '__APPNAME__' + } + ____APPNAME___picker_install + fi +fi diff --git a/internal/autocomplete/shellscripts/fish_picker.fish b/internal/autocomplete/shellscripts/fish_picker.fish new file mode 100644 index 00000000..96c065f5 --- /dev/null +++ b/internal/autocomplete/shellscripts/fish_picker.fish @@ -0,0 +1,120 @@ +# Optional image picker. Source after the ordinary fish completions. +# Keep custom bindings as shell-owned commands; never evaluate the input buffer. +if not status is-interactive; or not isatty stdin; or not isatty stdout; or not isatty stderr + return +end +if test "$TERM" = dumb; or test -z "$TERM" + return +end +if set -q __openai_picker_modes + return +end + +function openai_picker_disable + if not set -q __openai_picker_modes + return + end + for slot in (seq (count $__openai_picker_modes)) + set -l wrapper $__openai_picker_wrappers[$slot] + set -g $wrapper\_active 0 + set -l mode $__openai_picker_modes[$slot] + set -l owned_name __openai_picker_owned_$slot + set -l prior_name __openai_picker_prior_$slot + if test "$(bind --user --mode $mode \t 2>/dev/null | string collect)" = "$$owned_name" + bind --erase --user --mode $mode \t + if test -n "$$prior_name" + printf '%s\n' $$prior_name | source + end + functions --erase $wrapper + set --erase --global $wrapper\_active + end + set --erase --global __openai_picker_owned_$slot __openai_picker_prior_$slot + end + set --erase --global __openai_picker_modes __openai_picker_wrappers +end + +# fish initializes its default bindings lazily. Resolve them before saving Tab. +if not set -q fish_key_bindings + fish_default_key_bindings +end +set -g __openai_picker_modes +set -g __openai_picker_wrappers +if not set -q __openai_picker_serial + set -g __openai_picker_serial 0 +end +for mode in default insert + set -l prior (bind --user --mode $mode \t 2>/dev/null | string collect) + set -l binding "$prior" + if test -z "$binding" + set binding (bind --preset --mode $mode \t 2>/dev/null | string collect) + end + if test -z "$binding" + continue + end + # bind emits shell-escaped words. Tokenize them without executing anything. + set -l words + printf '%s\n' "$binding" | read --tokenize --array words + set -l position 2 + set -l next_mode '' + while string match --quiet -- '-*' "$words[$position]" + switch $words[$position] + case -M --mode + set position (math $position + 2) + case -m --sets-mode + set next_mode $words[(math $position + 1)] + set position (math $position + 2) + case --preset --user + set position (math $position + 1) + case '*' + break + end + end + set -l commands $words[(math $position + 1)..-1] + if test (count $commands) -eq 0 + continue + end + set -l input_functions (bind --function-names) + set -l all_input 1 + for original_command in $commands + if not contains -- "$original_command" $input_functions + set all_input 0 + end + end + set -g --append __openai_picker_modes $mode + set -l slot (count $__openai_picker_modes) + set -g __openai_picker_prior_$slot "$prior" + set -g __openai_picker_serial (math $__openai_picker_serial + 1) + set -l wrapper __openai_picker_tab_$__openai_picker_serial + set -l active_name $wrapper\_active + set -g $active_name 1 + set -g --append __openai_picker_wrappers $wrapper + function $wrapper --inherit-variable commands --inherit-variable all_input --inherit-variable next_mode --inherit-variable active_name + # Keep commandline's one output newline in the pattern. Removing it via + # command substitution would also trim newlines in the editor buffer. + set -l buffer (commandline --current-buffer | string collect --no-trim-newlines) + set -l app (string escape --style=regex -- '__APPNAME__') + if test "$$active_name" = 1; and isatty stdin; and isatty stdout; and isatty stderr; and test "$TERM" != dumb; and test (commandline --cursor) -eq (math (string length -- "$buffer") - 1); and string match --quiet --regex -- "\\A[ \\t]*"$app"[ \\t]+images[ \\t]+generate[ \\t]*\\n\\z" "$buffer"; and test "$(type --type __APPNAME__ 2>/dev/null)" = file + # The current command resolves to an external executable. + printf '\n' + env OPENAI_PICKER_SHELL=fish __APPNAME__ images generate + commandline --function repaint + return + end + + if test "$all_input" = 1 + commandline --function $commands + else + # These are the original, trusted fish binding commands. They are not + # commandline text, completions, or output from the CLI. + for original_command in $commands + eval $original_command + end + end + if test -n "$next_mode" + set -g fish_bind_mode $next_mode + end + end + + bind --user --mode $mode \t $wrapper + set -g __openai_picker_owned_$slot (bind --user --mode $mode \t | string collect) +end diff --git a/internal/autocomplete/shellscripts/zsh_picker.zsh b/internal/autocomplete/shellscripts/zsh_picker.zsh new file mode 100644 index 00000000..5dd6b8f8 --- /dev/null +++ b/internal/autocomplete/shellscripts/zsh_picker.zsh @@ -0,0 +1,97 @@ +# Optional image picker integration. Source after normal completion setup. +if [[ -o interactive && -o zle && -t 0 && -t 1 && -t 2 && -n ${TERM-} && ${TERM-} != dumb ]]; then + ____APPNAME___picker_matches() { + emulate -L zsh + setopt casematch + local pattern=$'^[ \t]*__APPNAME__[ \t]+images[ \t]+generate[ \t]*$' + [[ $BUFFER =~ $pattern && $CURSOR -eq ${#BUFFER} && $(whence -w '__APPNAME__') == '__APPNAME__: command' ]] + } + + ____APPNAME___picker_owns_tab() { + emulate -L zsh + local binding=$(bindkey -M "${KEYMAP:-main}" '^I') + [[ $binding == '"^I" ____APPNAME___picker_tab_emacs' || $binding == '"^I" ____APPNAME___picker_tab_viins' ]] + } + + ____APPNAME___picker_clear_hint() { + emulate -L zsh + if [[ ${____APPNAME___picker_hint_visible-} == 1 && ${POSTDISPLAY-} == ' [Tab: image options]' ]]; then + POSTDISPLAY='' + fi + typeset -g ____APPNAME___picker_hint_visible=0 + } + + ____APPNAME___picker_hint() { + emulate -L zsh + ____APPNAME___picker_clear_hint + if [[ ${____APPNAME___picker_enabled-} == 1 && -t 1 && -t 2 && -n ${TERM-} && ${TERM-} != dumb && -z $POSTDISPLAY ]] && + ____APPNAME___picker_owns_tab && ____APPNAME___picker_matches; then + POSTDISPLAY=' [Tab: image options]' + typeset -g ____APPNAME___picker_hint_visible=1 + fi + } + + ____APPNAME___picker_tab() { + emulate -L zsh + if [[ ${____APPNAME___picker_enabled-} == 1 && -t 1 && -t 2 && -n ${TERM-} && ${TERM-} != dumb ]] && + ____APPNAME___picker_owns_tab && ____APPNAME___picker_matches; then + ____APPNAME___picker_clear_hint + # ZLE redirects stdin away from the terminal while running widgets. + # Stderr was verified above, so duplicate that terminal for the child. + zle -I + OPENAI_PICKER_SHELL=zsh command '__APPNAME__' images generate <&2 + zle reset-prompt + return 0 + fi + zle "____APPNAME___picker_previous_$1" -- "${@:2}" + } + + ____APPNAME___picker_tab_emacs() { ____APPNAME___picker_tab emacs "$@"; } + ____APPNAME___picker_tab_viins() { ____APPNAME___picker_tab viins "$@"; } + + __APPNAME___picker_disable() { + emulate -L zsh + typeset -g ____APPNAME___picker_enabled=0 + ____APPNAME___picker_clear_hint + local keymap binding + for keymap in emacs viins; do + binding=$(bindkey -M "$keymap" '^I') + if [[ $binding == '"^I" ____APPNAME___picker_tab_'$keymap ]]; then + bindkey -M "$keymap" '^I' "${____APPNAME___picker_bindings[$keymap]}" + fi + done + add-zle-hook-widget -d line-pre-redraw ____APPNAME___picker_hint + add-zle-hook-widget -d line-finish ____APPNAME___picker_clear_hint + # Leave private widget aliases available for plugins that chained them. + # They delegate to the saved widget while the picker is disabled. + } + + () { + emulate -L zsh + zmodload zsh/zleparameter || return + autoload -Uz add-zle-hook-widget + typeset -gA ____APPNAME___picker_bindings + local keymap original + local -a binding + for keymap in emacs viins; do + binding=(${(z)$(bindkey -M "$keymap" '^I')}) + original=${(Q)binding[2]} + # Capture a delegate once. Recapturing a newer plugin that calls our + # wrapper would introduce a cycle when integration is enabled again. + if [[ -n ${____APPNAME___picker_bindings[$keymap]-} ]]; then + if [[ $original == ${____APPNAME___picker_bindings[$keymap]} && ${____APPNAME___picker_enabled-} != 1 ]]; then + bindkey -M "$keymap" '^I' "____APPNAME___picker_tab_$keymap" + fi + continue + fi + [[ -n ${widgets[$original]-} && $original != ____APPNAME___picker_tab_* ]] || continue + zle -A "$original" "____APPNAME___picker_previous_$keymap" || continue + ____APPNAME___picker_bindings[$keymap]=$original + zle -N "____APPNAME___picker_tab_$keymap" + bindkey -M "$keymap" '^I' "____APPNAME___picker_tab_$keymap" + done + typeset -g ____APPNAME___picker_enabled=1 + add-zle-hook-widget line-pre-redraw ____APPNAME___picker_hint + add-zle-hook-widget line-finish ____APPNAME___picker_clear_hint + } +fi diff --git a/pkg/custom/command.go b/pkg/custom/command.go index 06fca1a7..10b18258 100644 --- a/pkg/custom/command.go +++ b/pkg/custom/command.go @@ -20,6 +20,7 @@ func ConfigureCommand(root *cli.Command) { registerImageModels(root) configureReadableOutput(root) configureImageSaving(root) + configureImagePickerCompletion(root) registerImagePreviewCommands(root) configureReadableAudio(root) configureReadableSpeech(root) diff --git a/pkg/custom/image_picker_completion.go b/pkg/custom/image_picker_completion.go new file mode 100644 index 00000000..b283e6a1 --- /dev/null +++ b/pkg/custom/image_picker_completion.go @@ -0,0 +1,23 @@ +package custom + +import ( + "slices" + + "github.com/urfave/cli/v3" +) + +// Keep the optional current-session hook on the existing completion command. +func configureImagePickerCompletion(root *cli.Command) { + completion := root.Command("@completion") + if completion == nil { + return + } + for _, flag := range completion.Flags { + if slices.Contains(flag.Names(), "picker") { + return + } + } + completion.Flags = append(completion.Flags, + &cli.BoolFlag{Name: "picker", Usage: "Include the optional Tab shortcut for the image picker"}, + ) +} diff --git a/pkg/custom/local_errors.go b/pkg/custom/local_errors.go index 7ba11978..89237b7f 100644 --- a/pkg/custom/local_errors.go +++ b/pkg/custom/local_errors.go @@ -92,6 +92,7 @@ func knownLocalError(command *cli.Command, message string) string { "cannot read from stdin: stdin is already being used for the request body", "cannot read from stdin: stdin was already consumed by piped YAML/JSON input", "Setup help takes no additional arguments.", + "PowerShell uses normal Tab completion. Type openai images generate and press Enter to open the image picker.", "COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', or 'fish'", "COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', 'fish'": return message