From e9613afc05feaf6f8b77671b361d47cd3d7c1f22 Mon Sep 17 00:00:00 2001 From: reugn Date: Thu, 5 Dec 2024 12:27:16 +0200 Subject: [PATCH] refactor: internal packages structure --- README.md | 9 +- cmd/gemini/main.go | 2 +- internal/chat/chat.go | 132 ++++++---------------- internal/cli/command.go | 10 ++ internal/handler/gemini_query.go | 13 +-- internal/handler/handler.go | 3 + internal/handler/history_command.go | 8 +- internal/handler/input_mode_command.go | 24 ++-- internal/handler/model_command.go | 29 +++-- internal/handler/prompt_command.go | 11 +- internal/handler/quit_command.go | 5 +- internal/handler/response.go | 24 +--- internal/handler/system_command.go | 28 +++-- internal/handler/terminal_io.go | 22 ++++ internal/{cli => terminal}/color/color.go | 0 internal/terminal/io.go | 114 +++++++++++++++++++ internal/{cli => terminal}/prompt.go | 4 +- internal/{cli => terminal}/spinner.go | 2 +- 18 files changed, 265 insertions(+), 175 deletions(-) create mode 100644 internal/cli/command.go create mode 100644 internal/handler/terminal_io.go rename internal/{cli => terminal}/color/color.go (100%) create mode 100644 internal/terminal/io.go rename internal/{cli => terminal}/prompt.go (94%) rename internal/{cli => terminal}/spinner.go (98%) diff --git a/README.md b/README.md index 08e1cf2..6e5d290 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ See the [go install](https://go.dev/ref/mod#go-install) instructions for more in To use `gemini-cli`, you'll need an API key set in the `GEMINI_API_KEY` environment variable. If you don't already have one, create a key in [Google AI Studio](https://makersuite.google.com/app/apikey). -To set the environment variable in the terminal: -```console +Set the environment variable in the terminal: +```sh export GEMINI_API_KEY= ``` @@ -51,8 +51,9 @@ A short list of supported system commands: | !h | Select a history operation 3 | 1 System instruction (also known as "system prompt") is a more forceful prompt to the model. -The model will adhere the instructions more strongly than if they appeared in a normal prompt. -The system instructions must be specified by the user in the [configuration file](#configuration-file). +The model will follow instructions more closely than with a standard prompt. +The user must specify system instructions in the [configuration file](#configuration-file). +Note that not all generative models support them. 2 Model operations: * Select a generative model from the list of available models diff --git a/cmd/gemini/main.go b/cmd/gemini/main.go index 40060a2..ab46131 100644 --- a/cmd/gemini/main.go +++ b/cmd/gemini/main.go @@ -51,7 +51,7 @@ func run() int { return err } - chatHandler, err := chat.New(getCurrentUser(), chatSession, configuration, os.Stdout, &opts) + chatHandler, err := chat.New(getCurrentUser(), chatSession, configuration, &opts) if err != nil { return err } diff --git a/internal/chat/chat.go b/internal/chat/chat.go index d7f55eb..39024fe 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -1,25 +1,16 @@ package chat import ( - "errors" - "fmt" - "io" - "strings" - "time" - - "github.com/chzyer/readline" "github.com/reugn/gemini-cli/gemini" "github.com/reugn/gemini-cli/internal/cli" "github.com/reugn/gemini-cli/internal/config" "github.com/reugn/gemini-cli/internal/handler" + "github.com/reugn/gemini-cli/internal/terminal" ) // Chat handles the interactive exchange of messages between user and model. type Chat struct { - reader *readline.Instance - writer io.Writer - terminalPrompt *cli.Prompt - opts *Opts + io *terminal.IO geminiHandler handler.MessageHandler systemHandler handler.MessageHandler @@ -28,119 +19,68 @@ type Chat struct { // New returns a new Chat. func New( user string, session *gemini.ChatSession, - configuration *config.Configuration, writer io.Writer, opts *Opts, + configuration *config.Configuration, opts *Opts, ) (*Chat, error) { - reader, err := readline.NewEx(&readline.Config{}) - if err != nil { - return nil, err + terminalIOConfig := &terminal.IOConfig{ + User: user, + Multiline: opts.Multiline, + LineTerminator: opts.LineTerminator, } - terminalPrompt := cli.NewPrompt(user) - reader.SetPrompt(terminalPrompt.User) - if opts.Multiline { - // disable history for multiline input mode - reader.HistoryDisable() + terminalIO, err := terminal.NewIO(terminalIOConfig) + if err != nil { + return nil, err } - spinner := cli.NewSpinner(writer, time.Second, 5) - geminiHandler, err := handler.NewGeminiQuery(session, spinner, opts.Style) + geminiIO := handler.NewIO(terminalIO, terminalIO.Prompt.Gemini) + geminiHandler, err := handler.NewGeminiQuery(geminiIO, session, opts.Style) if err != nil { return nil, err } - systemHandler := handler.NewSystemCommand(session, configuration, reader, - &opts.Multiline, opts.GenerativeModel) + systemIO := handler.NewIO(terminalIO, terminalIO.Prompt.Cli) + systemHandler := handler.NewSystemCommand(systemIO, session, configuration, + opts.GenerativeModel) return &Chat{ - terminalPrompt: terminalPrompt, - reader: reader, - writer: writer, - opts: opts, - geminiHandler: geminiHandler, - systemHandler: systemHandler, + io: terminalIO, + geminiHandler: geminiHandler, + systemHandler: systemHandler, }, nil } // Start starts the main chat loop between user and model. func (c *Chat) Start() { for { - message, ok := c.read() - if !ok { + // read query from the user + message := c.io.Read() + if message == "" { continue } - // process the message - messageHandler, terminalPrompt := c.getHandlerPrompt(message) - response, quit := messageHandler.Handle(message) - _ = response.Print(c.writer, terminalPrompt) - - if quit { - break - } - } -} + // get handler for the read message + // the message is not empty here + messageHandler := c.getHandler(message[:1]) -func (c *Chat) read() (string, bool) { - if c.opts.Multiline { - return c.readMultiLine() - } - return c.readLine() -} + // write the agent terminal prompt + c.io.Write(messageHandler.TerminalPrompt()) -func (c *Chat) readLine() (string, bool) { - input, err := c.reader.Readline() - if err != nil { - return c.handleReadError(len(input), err) - } - return validateInput(input) -} + // process the message + response, quit := messageHandler.Handle(message) -func (c *Chat) readMultiLine() (string, bool) { - var builder strings.Builder - term := c.opts.LineTerminator - for { - input, err := c.reader.Readline() - if err != nil { - c.reader.SetPrompt(c.terminalPrompt.User) - return c.handleReadError(builder.Len()+len(input), err) - } + // write the response + c.io.Write(response.String()) - if strings.HasSuffix(input, term) || - strings.HasPrefix(input, handler.SystemCmdPrefix) { - builder.WriteString(strings.TrimSuffix(input, term)) + if quit { break } - - if builder.Len() == 0 { - c.reader.SetPrompt(c.terminalPrompt.UserNext) - } - - builder.WriteString(input + "\n") - } - c.reader.SetPrompt(c.terminalPrompt.User) - return validateInput(builder.String()) -} - -func (c *Chat) handleReadError(inputLen int, err error) (string, bool) { - if errors.Is(err, readline.ErrInterrupt) { - if inputLen == 0 { - return handler.SystemCmdPrefix + handler.SystemCmdQuit, true - } - } else { - handler.PrintError(c.writer, c.terminalPrompt.Cli, err) } - return "", false } -func (c *Chat) getHandlerPrompt(message string) (handler.MessageHandler, string) { - if strings.HasPrefix(message, handler.SystemCmdPrefix) { - return c.systemHandler, c.terminalPrompt.Cli +// getHandler returns the handler for the message. +func (c *Chat) getHandler(prefix string) handler.MessageHandler { + if prefix == cli.SystemCmdPrefix { + return c.systemHandler } - _, _ = fmt.Fprint(c.writer, c.terminalPrompt.Gemini) - return c.geminiHandler, "" -} - -func validateInput(input string) (string, bool) { - input = strings.TrimSpace(input) - return input, input != "" + return c.geminiHandler } diff --git a/internal/cli/command.go b/internal/cli/command.go new file mode 100644 index 0000000..6410242 --- /dev/null +++ b/internal/cli/command.go @@ -0,0 +1,10 @@ +package cli + +const ( + SystemCmdPrefix = "!" + SystemCmdQuit = "q" + SystemCmdSelectPrompt = "p" + SystemCmdSelectInputMode = "i" + SystemCmdModel = "m" + SystemCmdHistory = "h" +) diff --git a/internal/handler/gemini_query.go b/internal/handler/gemini_query.go index 8e37c48..c35a35c 100644 --- a/internal/handler/gemini_query.go +++ b/internal/handler/gemini_query.go @@ -6,39 +6,38 @@ import ( "github.com/charmbracelet/glamour" "github.com/reugn/gemini-cli/gemini" - "github.com/reugn/gemini-cli/internal/cli" ) // GeminiQuery processes queries to gemini models. // It implements the MessageHandler interface. type GeminiQuery struct { + *IO session *gemini.ChatSession - spinner *cli.Spinner renderer *glamour.TermRenderer } var _ MessageHandler = (*GeminiQuery)(nil) // NewGeminiQuery returns a new GeminiQuery message handler. -func NewGeminiQuery(session *gemini.ChatSession, spinner *cli.Spinner, - style string) (*GeminiQuery, error) { +func NewGeminiQuery(io *IO, session *gemini.ChatSession, style string) (*GeminiQuery, error) { renderer, err := glamour.NewTermRenderer(glamour.WithStylePath(style)) if err != nil { return nil, fmt.Errorf("failed to instantiate terminal renderer: %w", err) } return &GeminiQuery{ + IO: io, session: session, - spinner: spinner, renderer: renderer, }, nil } // Handle processes the chat message. func (h *GeminiQuery) Handle(message string) (Response, bool) { - h.spinner.Start() + h.terminal.Spinner.Start() + defer h.terminal.Spinner.Stop() + response, err := h.session.SendMessage(message) - h.spinner.Stop() if err != nil { return newErrorResponse(err), false } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 8822071..90fd0c0 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -5,4 +5,7 @@ type MessageHandler interface { // Handle processes the message and returns a response, along with a flag // indicating whether the application should terminate. Handle(message string) (Response, bool) + + // TerminalPrompt returns the terminal prompt for the handler. + TerminalPrompt() string } diff --git a/internal/handler/history_command.go b/internal/handler/history_command.go index 13ad898..0184554 100644 --- a/internal/handler/history_command.go +++ b/internal/handler/history_command.go @@ -21,6 +21,7 @@ var historyOptions = []string{ // HistoryCommand processes the chat history system commands. // It implements the MessageHandler interface. type HistoryCommand struct { + *IO session *gemini.ChatSession configuration *config.Configuration } @@ -28,9 +29,10 @@ type HistoryCommand struct { var _ MessageHandler = (*HistoryCommand)(nil) // NewHistoryCommand returns a new HistoryCommand. -func NewHistoryCommand(session *gemini.ChatSession, +func NewHistoryCommand(io *IO, session *gemini.ChatSession, configuration *config.Configuration) *HistoryCommand { return &HistoryCommand{ + IO: io, session: session, configuration: configuration, } @@ -60,12 +62,14 @@ func (h *HistoryCommand) Handle(_ string) (Response, bool) { // handleClear handles the chat history clear request. func (h *HistoryCommand) handleClear() Response { + h.terminal.Write(h.terminalPrompt) h.session.ClearHistory() return dataResponse("Cleared the chat history.") } // handleStore handles the chat history store request. func (h *HistoryCommand) handleStore() Response { + defer h.terminal.Write(h.terminalPrompt) historyLabel, err := h.promptHistoryLabel() if err != nil { return newErrorResponse(err) @@ -87,6 +91,7 @@ func (h *HistoryCommand) handleStore() Response { // handleLoad handles the chat history load request. func (h *HistoryCommand) handleLoad() Response { + defer h.terminal.Write(h.terminalPrompt) label, history, err := h.loadHistory() if err != nil { return newErrorResponse(err) @@ -98,6 +103,7 @@ func (h *HistoryCommand) handleLoad() Response { // handleDelete handles deletion of the stored history records. func (h *HistoryCommand) handleDelete() Response { + h.terminal.Write(h.terminalPrompt) h.configuration.Data.History = make(map[string][]*gemini.SerializableContent) if err := h.configuration.Flush(); err != nil { return newErrorResponse(err) diff --git a/internal/handler/input_mode_command.go b/internal/handler/input_mode_command.go index 979b625..8b5484a 100644 --- a/internal/handler/input_mode_command.go +++ b/internal/handler/input_mode_command.go @@ -3,7 +3,6 @@ package handler import ( "fmt" - "github.com/chzyer/readline" "github.com/manifoldco/promptui" ) @@ -15,42 +14,41 @@ var inputModeOptions = []string{ // InputModeCommand processes the chat input mode system command. // It implements the MessageHandler interface. type InputModeCommand struct { - reader *readline.Instance - multiline *bool + *IO } var _ MessageHandler = (*InputModeCommand)(nil) // NewInputModeCommand returns a new InputModeCommand. -func NewInputModeCommand(reader *readline.Instance, multiline *bool) *InputModeCommand { +func NewInputModeCommand(io *IO) *InputModeCommand { return &InputModeCommand{ - reader: reader, - multiline: multiline, + IO: io, } } // Handle processes the chat input mode system command. func (h *InputModeCommand) Handle(_ string) (Response, bool) { + defer h.terminal.Write(h.terminalPrompt) multiline, err := h.selectInputMode() if err != nil { return newErrorResponse(err), false } - if *h.multiline == multiline { + if h.terminal.Config.Multiline == multiline { // the same input mode is selected return dataResponse(unchangedMessage), false } - *h.multiline = multiline - if *h.multiline { + h.terminal.Config.Multiline = multiline + if h.terminal.Config.Multiline { // disable history for multi-line messages since it is // unusable for future requests - h.reader.HistoryDisable() + h.terminal.Reader.HistoryDisable() } else { - h.reader.HistoryEnable() + h.terminal.Reader.HistoryEnable() } - mode := inputModeOptions[modeIndex(*h.multiline)] + mode := inputModeOptions[modeIndex(h.terminal.Config.Multiline)] return dataResponse(fmt.Sprintf("Switched to %q input mode.", mode)), false } @@ -61,7 +59,7 @@ func (h *InputModeCommand) selectInputMode() (bool, error) { Label: "Select input mode", HideSelected: true, Items: inputModeOptions, - CursorPos: modeIndex(*h.multiline), + CursorPos: modeIndex(h.terminal.Config.Multiline), } _, result, err := prompt.Run() diff --git a/internal/handler/model_command.go b/internal/handler/model_command.go index 0cd918a..a1a5f71 100644 --- a/internal/handler/model_command.go +++ b/internal/handler/model_command.go @@ -16,17 +16,19 @@ var modelOptions = []string{ // ModelCommand processes the chat model system commands. // It implements the MessageHandler interface. type ModelCommand struct { - session *gemini.ChatSession - currentModel string + *IO + session *gemini.ChatSession + generativeModelName string } var _ MessageHandler = (*ModelCommand)(nil) // NewModelCommand returns a new ModelCommand. -func NewModelCommand(session *gemini.ChatSession, modelName string) *ModelCommand { +func NewModelCommand(io *IO, session *gemini.ChatSession, modelName string) *ModelCommand { return &ModelCommand{ - session: session, - currentModel: modelName, + IO: io, + session: session, + generativeModelName: modelName, } } @@ -51,24 +53,29 @@ func (h *ModelCommand) Handle(_ string) (Response, bool) { // handleSelectModel handles the generative model selection. func (h *ModelCommand) handleSelectModel() Response { - model, err := h.selectModel(h.session.ListModels()) + defer h.terminal.Write(h.terminalPrompt) + modelName, err := h.selectModel(h.session.ListModels()) if err != nil { return newErrorResponse(err) } - if h.currentModel == model { + if h.generativeModelName == modelName { return dataResponse(unchangedMessage) } - modelBuilder := h.session.CopyModelBuilder().WithName(model) + modelBuilder := h.session.CopyModelBuilder().WithName(modelName) h.session.SetModel(modelBuilder) - h.currentModel = model + h.generativeModelName = modelName - return dataResponse(fmt.Sprintf("Selected %q generative model.", model)) + return dataResponse(fmt.Sprintf("Selected %q generative model.", modelName)) } // handleSelectModel handles the current generative model info request. func (h *ModelCommand) handleModelInfo() Response { + h.terminal.Write(h.terminalPrompt) + h.terminal.Spinner.Start() + defer h.terminal.Spinner.Stop() + modelInfo, err := h.session.ModelInfo() if err != nil { return newErrorResponse(err) @@ -98,7 +105,7 @@ func (h *ModelCommand) selectModel(models []string) (string, error) { Label: "Select generative session", HideSelected: true, Items: models, - CursorPos: slices.Index(models, h.currentModel), + CursorPos: slices.Index(models, h.generativeModelName), } _, result, err := prompt.Run() diff --git a/internal/handler/prompt_command.go b/internal/handler/prompt_command.go index 15b7ea0..70cb7c4 100644 --- a/internal/handler/prompt_command.go +++ b/internal/handler/prompt_command.go @@ -13,18 +13,20 @@ import ( // SystemPromptCommand processes the chat prompt system command. // It implements the MessageHandler interface. type SystemPromptCommand struct { + *IO session *gemini.ChatSession applicationData *config.ApplicationData - currentPrompt string + systemPrompt string } var _ MessageHandler = (*SystemPromptCommand)(nil) // NewSystemPromptCommand returns a new SystemPromptCommand. -func NewSystemPromptCommand(session *gemini.ChatSession, +func NewSystemPromptCommand(io *IO, session *gemini.ChatSession, applicationData *config.ApplicationData) *SystemPromptCommand { return &SystemPromptCommand{ + IO: io, session: session, applicationData: applicationData, } @@ -32,6 +34,7 @@ func NewSystemPromptCommand(session *gemini.ChatSession, // Handle processes the chat prompt system command. func (h *SystemPromptCommand) Handle(_ string) (Response, bool) { + defer h.terminal.Write(h.terminalPrompt) label, systemPrompt, err := h.selectSystemPrompt() if err != nil { return newErrorResponse(err), false @@ -57,7 +60,7 @@ func (h *SystemPromptCommand) selectSystemPrompt() (string, *genai.Content, erro Label: "Select system instruction", HideSelected: true, Items: promptNames, - CursorPos: slices.Index(promptNames, h.currentPrompt), + CursorPos: slices.Index(promptNames, h.systemPrompt), } _, result, err := prompt.Run() @@ -65,7 +68,7 @@ func (h *SystemPromptCommand) selectSystemPrompt() (string, *genai.Content, erro return result, nil, err } - h.currentPrompt = result + h.systemPrompt = result if result == empty { return result, nil, nil } diff --git a/internal/handler/quit_command.go b/internal/handler/quit_command.go index af2f408..39d45e4 100644 --- a/internal/handler/quit_command.go +++ b/internal/handler/quit_command.go @@ -3,13 +3,14 @@ package handler // QuitCommand processes the chat quit system command. // It implements the MessageHandler interface. type QuitCommand struct { + *IO } var _ MessageHandler = (*QuitCommand)(nil) // NewQuitCommand returns a new QuitCommand. -func NewQuitCommand() *QuitCommand { - return &QuitCommand{} +func NewQuitCommand(io *IO) *QuitCommand { + return &QuitCommand{IO: io} } // Handle processes the chat quit command. diff --git a/internal/handler/response.go b/internal/handler/response.go index f748b2d..c67c4f0 100644 --- a/internal/handler/response.go +++ b/internal/handler/response.go @@ -2,28 +2,21 @@ package handler import ( "fmt" - "io" - "github.com/reugn/gemini-cli/internal/cli/color" -) - -const ( - empty = "Empty" - unchangedMessage = "The selection is unchanged." + "github.com/reugn/gemini-cli/internal/terminal" ) // Response represents a response from a chat message handler. type Response interface { - Print(w io.Writer, prompt string) error + fmt.Stringer } type dataResponse string var _ Response = (*dataResponse)(nil) -func (r dataResponse) Print(w io.Writer, prompt string) error { - _, err := fmt.Fprintf(w, "%s%s\n", prompt, r) - return err +func (r dataResponse) String() string { + return fmt.Sprintf("%s\n", string(r)) } type errorResponse struct { @@ -36,11 +29,6 @@ func newErrorResponse(err error) errorResponse { var _ Response = (*errorResponse)(nil) -func (r errorResponse) Print(w io.Writer, prompt string) error { - _, err := fmt.Fprintf(w, "%s%s\n", prompt, color.Red(r.Error())) - return err -} - -func PrintError(w io.Writer, prompt string, err error) { - _ = newErrorResponse(err).Print(w, prompt) +func (r errorResponse) String() string { + return fmt.Sprintf("%s\n", terminal.Error(r.Error())) } diff --git a/internal/handler/system_command.go b/internal/handler/system_command.go index f05c0cd..9a718ad 100644 --- a/internal/handler/system_command.go +++ b/internal/handler/system_command.go @@ -4,47 +4,45 @@ import ( "fmt" "strings" - "github.com/chzyer/readline" "github.com/reugn/gemini-cli/gemini" + "github.com/reugn/gemini-cli/internal/cli" "github.com/reugn/gemini-cli/internal/config" ) const ( - SystemCmdPrefix = "!" - SystemCmdQuit = "q" - systemCmdSelectPrompt = "p" - systemCmdSelectInputMode = "i" - systemCmdModel = "m" - systemCmdHistory = "h" + empty = "Empty" + unchangedMessage = "The selection is unchanged." ) // SystemCommand processes chat system commands; implements the MessageHandler interface. // It aggregates the processing by delegating it to one of the underlying handlers. type SystemCommand struct { + *IO handlers map[string]MessageHandler } var _ MessageHandler = (*SystemCommand)(nil) // NewSystemCommand returns a new SystemCommand. -func NewSystemCommand(session *gemini.ChatSession, configuration *config.Configuration, - reader *readline.Instance, multiline *bool, modelName string) *SystemCommand { +func NewSystemCommand(io *IO, session *gemini.ChatSession, configuration *config.Configuration, + modelName string) *SystemCommand { handlers := map[string]MessageHandler{ - SystemCmdQuit: NewQuitCommand(), - systemCmdSelectPrompt: NewSystemPromptCommand(session, configuration.Data), - systemCmdSelectInputMode: NewInputModeCommand(reader, multiline), - systemCmdModel: NewModelCommand(session, modelName), - systemCmdHistory: NewHistoryCommand(session, configuration), + cli.SystemCmdQuit: NewQuitCommand(io), + cli.SystemCmdSelectPrompt: NewSystemPromptCommand(io, session, configuration.Data), + cli.SystemCmdSelectInputMode: NewInputModeCommand(io), + cli.SystemCmdModel: NewModelCommand(io, session, modelName), + cli.SystemCmdHistory: NewHistoryCommand(io, session, configuration), } return &SystemCommand{ + IO: io, handlers: handlers, } } // Handle processes the chat system command. func (s *SystemCommand) Handle(message string) (Response, bool) { - if !strings.HasPrefix(message, SystemCmdPrefix) { + if !strings.HasPrefix(message, cli.SystemCmdPrefix) { return newErrorResponse(fmt.Errorf("system command mismatch")), false } diff --git a/internal/handler/terminal_io.go b/internal/handler/terminal_io.go new file mode 100644 index 0000000..9dc2f52 --- /dev/null +++ b/internal/handler/terminal_io.go @@ -0,0 +1,22 @@ +package handler + +import "github.com/reugn/gemini-cli/internal/terminal" + +// IO encapsulates terminal details for handlers. +type IO struct { + terminal *terminal.IO + terminalPrompt string +} + +// NewIO returns a new IO. +func NewIO(terminal *terminal.IO, terminalPrompt string) *IO { + return &IO{ + terminal: terminal, + terminalPrompt: terminalPrompt, + } +} + +// TerminalPrompt returns the terminal prompt string. +func (io *IO) TerminalPrompt() string { + return io.terminalPrompt +} diff --git a/internal/cli/color/color.go b/internal/terminal/color/color.go similarity index 100% rename from internal/cli/color/color.go rename to internal/terminal/color/color.go diff --git a/internal/terminal/io.go b/internal/terminal/io.go new file mode 100644 index 0000000..8f22bdb --- /dev/null +++ b/internal/terminal/io.go @@ -0,0 +1,114 @@ +package terminal + +import ( + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/chzyer/readline" + "github.com/reugn/gemini-cli/internal/cli" + "github.com/reugn/gemini-cli/internal/terminal/color" +) + +var Error = color.Red + +// IOConfig represents the configuration settings for IO. +type IOConfig struct { + User string + Multiline bool + LineTerminator string +} + +// IO encapsulates input/output operations. +type IO struct { + Reader *readline.Instance + Prompt *Prompt + Spinner *Spinner + writer io.Writer + + Config *IOConfig +} + +// NewIO returns a new IO based on the provided configuration. +func NewIO(config *IOConfig) (*IO, error) { + reader, err := readline.NewEx(&readline.Config{}) + if err != nil { + return nil, err + } + + terminalPrompt := NewPrompt(config.User) + reader.SetPrompt(terminalPrompt.User) + if config.Multiline { + // disable history for multiline input mode + reader.HistoryDisable() + } + + return &IO{ + Reader: reader, + Prompt: terminalPrompt, + Spinner: NewSpinner(reader.Stdout(), time.Second, 5), + writer: reader.Stdout(), + Config: config, + }, nil +} + +// Read reads input from the underlying source and returns it as a string. +// If multiline is true, it reads all available lines; otherwise, it reads a single line. +func (io *IO) Read() string { + if io.Config.Multiline { + return io.readMultiLine() + } + return io.readLine() +} + +// Write writes the given string data to the underlying data stream. +func (io *IO) Write(data string) { + _, _ = fmt.Fprint(io.writer, data) +} + +func (io *IO) readLine() string { + input, err := io.Reader.Readline() + if err != nil { + return io.handleReadError(err, len(input)) + } + return strings.TrimSpace(input) +} + +func (io *IO) readMultiLine() string { + defer io.Reader.SetPrompt(io.Prompt.User) + var builder strings.Builder + for { + input, err := io.Reader.Readline() + if err != nil { + return io.handleReadError(err, builder.Len()+len(input)) + } + + if strings.HasSuffix(input, io.Config.LineTerminator) || + strings.HasPrefix(input, cli.SystemCmdPrefix) { + builder.WriteString(strings.TrimSuffix(input, io.Config.LineTerminator)) + break + } + + if builder.Len() == 0 { + io.Reader.SetPrompt(io.Prompt.UserNext) + } + + builder.WriteString(input) + builder.WriteRune('\n') + } + return strings.TrimSpace(builder.String()) +} + +func (io *IO) handleReadError(err error, inputLen int) string { + if errors.Is(err, readline.ErrInterrupt) { + if inputLen == 0 { + // handle as the quit command + return cli.SystemCmdPrefix + cli.SystemCmdQuit + } + } else { + io.Write(fmt.Sprintf("%s%s\n", io.Prompt.Cli, Error(err.Error()))) + } + return "" +} diff --git a/internal/cli/prompt.go b/internal/terminal/prompt.go similarity index 94% rename from internal/cli/prompt.go rename to internal/terminal/prompt.go index c5104fb..e4c0337 100644 --- a/internal/cli/prompt.go +++ b/internal/terminal/prompt.go @@ -1,11 +1,11 @@ -package cli +package terminal import ( "fmt" "strings" "github.com/muesli/termenv" - "github.com/reugn/gemini-cli/internal/cli/color" + "github.com/reugn/gemini-cli/internal/terminal/color" ) const ( diff --git a/internal/cli/spinner.go b/internal/terminal/spinner.go similarity index 98% rename from internal/cli/spinner.go rename to internal/terminal/spinner.go index fc92562..e2b40c4 100644 --- a/internal/cli/spinner.go +++ b/internal/terminal/spinner.go @@ -1,4 +1,4 @@ -package cli +package terminal import ( "bufio"