From 280259deb57d8c18f7655e4ecd79ba137ca0a37c Mon Sep 17 00:00:00 2001
From: flu0r1ne <flu0r1ne@flu0r1ne.net>
Date: Thu, 11 May 2023 01:27:58 -0500
Subject: Add slash command and editing with an arbitrary editor

---
 src/gpt_chat_cli/argparsing.py    |  11 ++-
 src/gpt_chat_cli/argvalidation.py |   4 +-
 src/gpt_chat_cli/cmd.py           | 166 +++++++++++++++++++++++++-------------
 src/gpt_chat_cli/color.py         |  20 +++++
 4 files changed, 140 insertions(+), 61 deletions(-)

diff --git a/src/gpt_chat_cli/argparsing.py b/src/gpt_chat_cli/argparsing.py
index a96e81c..703a13b 100644
--- a/src/gpt_chat_cli/argparsing.py
+++ b/src/gpt_chat_cli/argparsing.py
@@ -124,14 +124,14 @@ def _construct_parser() \
         "-n",
         "--n-completions",
         type=int,
-        default=os.getenv('f{_GPT_CLI_ENV_PREFIX}N_COMPLETIONS', 1),
+        default=os.getenv(f'{_GPT_CLI_ENV_PREFIX}N_COMPLETIONS', 1),
         help="How many chat completion choices to generate for each input message.",
     )
 
     parser.add_argument(
         "--system-message",
         type=str,
-        default=os.getenv('f{_GPT_CLI_ENV_PREFIX}SYSTEM_MESSAGE'),
+        default=os.getenv(f'{_GPT_CLI_ENV_PREFIX}SYSTEM_MESSAGE'),
         help="Specify an alternative system message.",
     )
 
@@ -174,6 +174,13 @@ def _construct_parser() \
         help="Start an interactive session"
     )
 
+    parser.add_argument(
+        "--interactive-editor",
+        type=str,
+        default=os.getenv(f'{_GPT_CLI_ENV_PREFIX}INTERACTIVE_EDITOR'),
+        help="The editor which is launched by default using the /edit command in interactive mode"
+    )
+
     initial_prompt = parser.add_mutually_exclusive_group()
 
     initial_prompt.add_argument(
diff --git a/src/gpt_chat_cli/argvalidation.py b/src/gpt_chat_cli/argvalidation.py
index 16987a9..8e6ef52 100644
--- a/src/gpt_chat_cli/argvalidation.py
+++ b/src/gpt_chat_cli/argvalidation.py
@@ -53,6 +53,7 @@ class Arguments:
     openai_key: str
     system_message: Optional[str] = None
     debug_args: Optional[DebugArguments] = None
+    interactive_editor: Optional[str] = None
 
 def post_process_raw_args(raw_args : RawArguments) -> Arguments:
     _populate_defaults(raw_args)
@@ -104,7 +105,8 @@ def _restructure_arguments(raw_args : RawArguments) -> Arguments:
            version=args.version,
            list_models=args.list_models,
            interactive=args.interactive,
-           system_message=args.system_message
+           system_message=args.system_message,
+           interactive_editor=args.interactive_editor
     )
 
 def _die_validation_err(err : str):
diff --git a/src/gpt_chat_cli/cmd.py b/src/gpt_chat_cli/cmd.py
index 86ce0e6..899c705 100644
--- a/src/gpt_chat_cli/cmd.py
+++ b/src/gpt_chat_cli/cmd.py
@@ -10,6 +10,10 @@ from collections import defaultdict
 from dataclasses import dataclass
 from typing import Tuple, Optional
 
+import subprocess
+import tempfile
+import os
+
 from .openai_wrappers import (
     create_chat_completion,
     OpenAIChatResponse,
@@ -30,8 +34,13 @@ from .argvalidation import (
 )
 
 from .version import VERSION
-from .color import get_color_codes
+from .color import (
+    get_color_codes,
+    surround_ansi_escapes
+)
+
 from .chat_colorizer import ChatColorizer
+from .prompt import Prompter
 
 ###########################
 ####      UTILS        ####
@@ -63,11 +72,56 @@ def get_system_message(system_message : Optional[str]):
 
     return ChatMessage(Role.SYSTEM, system_message)
 
-def enable_emacs_editing():
-    try:
-        import readline
-    except ImportError:
-        pass
+def _resolve_system_editor() -> Optional[str]:
+    '''
+        Attempt to resolve the system if one is not explicitly specified.
+        This is either the editor stored in the EDITOR environmental variable
+        or one of editor, vim, emacs, vi, or nano.
+    '''
+
+    fallback_editors = [
+        "editor", # debian default
+        "vim",
+        "emacs",
+        "vi",
+        "nano",
+    ]
+
+    editor = os.getenv("EDITOR")
+
+    if editor:
+        return editor
+
+    paths = os.getenv("PATH")
+
+    if not paths:
+        return None
+
+    for editor in fallback_editors:
+        for path in paths.split(os.pathsep):
+            if os.path.exists(os.path.join(path, editor)):
+                return editor
+
+    return None
+
+def _launch_interactive_editor(editor : Optional[str] = None) -> str:
+
+    with tempfile.NamedTemporaryFile(suffix="gpt.msg") as tmp_file:
+
+        editor = editor or _resolve_system_editor()
+
+        try:
+            subprocess.call([editor, tmp_file.name])
+        except FileNotFoundError:
+            print(f'error: the specified editor \"{editor}\" does not exist')
+            sys.exit(1)
+
+        # Read the resulting file into a string
+        with open(tmp_file.name, "r") as edited_file:
+            edited_content = edited_file.read()
+
+        return edited_content
+
 
 ###########################
 ####   SAVE / REPLAY   ####
@@ -212,26 +266,6 @@ def print_streamed_response(
 ####    COMMANDS     ####
 #########################
 
-def surround_ansi_escapes(prompt, start = "\x01", end = "\x02"):
-        '''
-        Fixes issue on Linux with the readline module
-        See: https://github.com/python/cpython/issues/61539
-        '''
-        escaped = False
-        result = ""
-
-        for c in prompt:
-                if c == "\x1b" and not escaped:
-                        result += start + c
-                        escaped = True
-                elif c.isalpha() and escaped:
-                        result += c + end
-                        escaped = False
-                else:
-                        result += c
-
-        return result
-
 def version():
     print(f'version {VERSION}')
 
@@ -241,30 +275,22 @@ def list_models():
 
 def interactive(args : Arguments):
 
-    enable_emacs_editing()
-
-    COLOR_CODE = get_color_codes(no_color = not args.display_args.color)
-
     completion_args = args.completion_args
     display_args = args.display_args
 
-    hist = [ get_system_message( args.system_message ) ]
+    system_message = get_system_message(args.system_message)
+    interactive_editor = args.interactive_editor
 
-    PROMPT = f'[{COLOR_CODE.WHITE}#{COLOR_CODE.RESET}] '
-    PROMPT = surround_ansi_escapes(PROMPT)
+    hist = [ system_message ]
 
-    def prompt_message() -> bool:
-
-        # Control-D closes the input stream
-        try:
-            message = input( PROMPT )
-        except (EOFError, KeyboardInterrupt):
-            print()
-            return False
+    no_color = not display_args.color
 
-        hist.append( ChatMessage( Role.USER, message ) )
+    prompter = Prompter(no_color = no_color)
 
-        return True
+    CLEAR = prompter.add_command('clear', 'clears the context (excluding the system message)')
+    EDIT = prompter.add_command('edit', 'launches a terminal editor')
+    EXIT = prompter.add_command('exit', 'exit the terminal')
+    HELP = prompter.add_command('help', 'print all interactive commands')
 
     print(f'GPT Chat CLI version {VERSION}')
     print(f'Press Control-D to exit')
@@ -273,26 +299,50 @@ def interactive(args : Arguments):
 
     if initial_message:
         print( PROMPT, initial_message, sep='', flush=True )
-        hist.append( ChatMessage( Role.USER, initial_message ) )
-    else:
-        if not prompt_message():
-            return
 
-    while True:
+    with prompter as prompt:
+        while True:
+            try:
+                if initial_message:
+                    cmd, args = None, initial_message
+                    initial_message = None
+                else:
+                    cmd, args = prompt.input()
+
+                if cmd == CLEAR:
+                    hist = [ system_message ]
+                    continue
+                elif cmd == EDIT:
+                    message = _launch_interactive_editor(
+                        editor=interactive_editor
+                    )
+                    print( prompt.prompt, end='')
+                    print( message, end='' )
+                elif cmd == EXIT:
+                    return
+                elif cmd == HELP:
+                    prompt.print_help()
+                    continue
+                else:
+                    message = args
 
-        completion = create_chat_completion(hist, completion_args)
+                if message == '':
+                    continue
 
-        try:
-            response = print_streamed_response(
-                display_args, completion, 1, return_responses=True,
-            )[0]
+                hist.append( ChatMessage( Role.USER, message ) )
+
+                completion = create_chat_completion(hist, completion_args)
 
-            hist.append( ChatMessage(Role.ASSISTANT, response) )
-        except KeyboardInterrupt:
-            print()
+                response = print_streamed_response(
+                    display_args, completion, 1, return_responses=True,
+                )[0]
 
-        if not prompt_message():
-            return
+                hist.append( ChatMessage(Role.ASSISTANT, response) )
+            except KeyboardInterrupt: # Skip to next prompt
+                continue
+            except EOFError: # Exit on Control-D
+                print()
+                sys.exit(1)
 
 def singleton(args: Arguments):
     completion_args = args.completion_args
diff --git a/src/gpt_chat_cli/color.py b/src/gpt_chat_cli/color.py
index ce1b182..9de82f1 100644
--- a/src/gpt_chat_cli/color.py
+++ b/src/gpt_chat_cli/color.py
@@ -90,3 +90,23 @@ def get_color_codes(no_color=False) -> ColorCode:
         return NoColorColorCode
     else:
         return VT100ColorCode
+
+def surround_ansi_escapes(prompt, start = "\x01", end = "\x02"):
+        '''
+        Fixes issue on Linux with the readline module
+        See: https://github.com/python/cpython/issues/61539
+        '''
+        escaped = False
+        result = ""
+
+        for c in prompt:
+                if c == "\x1b" and not escaped:
+                        result += start + c
+                        escaped = True
+                elif c.isalpha() and escaped:
+                        result += c + end
+                        escaped = False
+                else:
+                        result += c
+
+        return result
-- 
cgit v1.2.3