New Component: streamlit-code-editor, a react-ace code editor customized to fit with streamlit with some extra goodies added on top

Hi,
I have created a code editor component based on react-ace but whose appearance is customized to fit with Streamlit’s look. I also added the ability/option to add a customizable info bar, menu bar, and customizable buttons.

I am relatively new to Streamlit and was well into this project when I learned of the already existing Ace Editor component (okld’s Ace Editor). However, I believe there are enough differences and additions to warrant sharing.

Warning: This project started as mostly an excuse to learn React (and I got hooked real quick :grin: ) and thus I do not consider it production ready.

Installation:

pip install streamlit_code_editor

Resources:

Demo reel:
basic settings

demo-pt1

adding info bar and custom buttons

demo-pt2

7 Likes

Hi @bouzidanas

Thanks for creating this new code editor component!

Best regards,
Chanin

1 Like

Hey again,

I just want to add some updates for those who have started using this component or are planning to.

First, streamlit-code-editor has moved to a new location since version 0.0.9. Check it out here:

Second, if you look at the gifs in top post here, you may notice that the editor flashes when any of the settings in the inputs above it are changed. This happens for two reasons:

  1. The editor doesnt have a fixed key set (using key= argument) and so it is replaced by a newly created one each time its arguments change or that part of the page is rerun. This results in the collapsing expanding effect seen in the gif.

  2. The custom themes used by this component are loaded dynamically which results in a delay compared to default theme loaded instantly. This results in the color change that is most noticeable when applying the dark custom theme.

I am happy to say that these issues have been resolve (as far as I can tell). The custom themes are loaded instantly.

The first issue should be resolved by using a fixed key and it is, but using a fixed key removes the ability to update/change/reset the contents of the editor programmatically. This was intentional to prevent loss of the latest edits made by the user. However, the edits arent really lost as they are stored in the edit history so a simple undo will restore anything lost which is what encouraged me to add the ability to force the code editor to respond to changed inputs when key is fixed. To do this, you set the newly added argument, allow_reset=True (defaults to False).

There are other things you will find in the latest version like improved scrollbar behavior and a new bindKey: {"win": "key-combo", "mac": "key-combo"} option for the custom buttons that allows you to set a keyboard shortcut to trigger a button.

More great things are planned so stay tuned!

how to get only selected code in response dict

The features that you could use to do that are not yet built into the component. I recommend you make a feature request (issue) on the github and I will get around to it when I can.

Hey again,

Just want to let you know that I have added a new command called returnSelection which will return the selected text in the response dictionary.

You can add a button or a shortcut to trigger the response. For more on adding a button that triggers this new command, see the Advanced Usage section of the docs.

For example,

import json
import streamlit as st
from code_editor import code_editor

# code editor config variables
height = [19, 22]
language="python"
theme="default"
shortcuts="vscode"
focus=False
wrap=True
editor_btns = [{
    "name": "Run",
    "feather": "Play",
    "primary": true,
    "hasText": true,
    "showWithIcon": true,
    "commands": ["returnSelection"],
    "style": {"bottom": "0.44rem", "right": "0.4rem"}
  }]
sample_python_code = '''# This is some sample code
a=2
if a > 1:
    print("The value is greater than 1")
'''

# code editor
response_dict = code_editor(sample_python_code,  height = height, lang=language, theme=theme, shortcuts=shortcuts, focus=focus, buttons=editor_btns, options={"wrap": wrap})

# show response dict
if len(response_dict['id']) != 0 and ( response_dict['type'] == "selection" or response_dict['type'] == "submit" ):
    st.write(response_dict)

This new command exists in the newest version (currently 0.1.12) on npm.

2 Likes

Hello, great work!
I’ve gone through the documentation yet I haven’t figured out how to enable interpretation within the code sample (sample_python_code). In your example the string is fixed, however, can it be adjusted by the user within the application and received by a variable (sample_python_code)?

If I understand you correctly, yes. Perhaps it is not explained very well in the docs (I will try to improve the docs more in the future), but the content of the code editor is editable and changeable and to get the changed code back (as the response/return value in streamlit), you have to trigger a response command. There are two ways to do so, by adding a button that calls the response command when clicked, or by setting a keyboard shortcut.

By default, code editor works like st.text_area. For the user to ‘submit’ the text in the text area, they have to press Ctrl + Enter when they are done changing the text. Same thing with code editor. The default behavior is that the user needs to press Ctrl + Enter keyboard shortcut after they are done changing the code in the editor and they want the changes seen by the app. Behind the scenes, the Ctrl + Enter keyboard shortcut triggers the submit command which is one of the response commands that can be used to send back stuff to the streamlit app.

You can test this out in the demo. Change the code in the editor and then press Ctrl + Enter. To see it working with a button, open the Components expander and check the custom buttons checkbox. Then when you hover over the editor, the custom buttons will appear. Fyi, the Run button at the bottom triggers the submit command and so should do the same thing as the keyboard shortcut when you click it.

Now, what happens when one of these response commands are triggered? Well, what happens is that the code_editor function in the streamlit script returns a dictionary containing the code among other things like response type and response id and cursor position when response was triggered.

So how would I update the code without using ctrl + enter:

code = code_editor(“”)

# Button to execute code
if st.button("Run Code"):
    execute_code(code['text'])

Hello, this may be a late response however I’d like to pitch in on how to capture the selected code parameter to perform actions upon. I’d also love to see line numbering added to this code editor. @bouzidanas

import json
import streamlit as st
from code_editor import code_editor

# code editor config variables
height = [19, 22]
language="python"
theme="default"
shortcuts="vscode"
focus=False
wrap=True
editor_btns = [{
    "name": "Run",
    "feather": "Play",
    "primary": True,
    "hasText": True,
    "showWithIcon": True,
    "commands": ["submit"],
    "style": {"bottom": "0.44rem", "right": "0.4rem"}
  }]
sample_python_code = '''# This is some sample code
a=2
if a > 1:
    print("The value is greater than 1")
'''

# code editor
response_dict = code_editor(sample_python_code,  height = height, lang=language, theme=theme, shortcuts=shortcuts, focus=focus, buttons=editor_btns, options={"wrap": wrap})

# show response dict
if len(response_dict['id']) != 0 and ( response_dict['type'] == "selection" or response_dict['type'] == "submit" ):
    # Capture the text part
    code_text = response_dict['text']
    st.code(code_text, language='python') #Captured the code parameter.

Hi @abd_kmm,

Line numbering isnt turned on by default but you can turn it on with one of the config options so in the code_editor function just add options={"wrap": True, "showLineNumbers": True}

Hello @bouzidanas, that didn’t work on my side. Maybe I have done something wrong here?

import json
import streamlit as st
from code_editor import code_editor

# code editor config variables
height = [19, 22]
language="python"
theme="default"
shortcuts="vscode"
focus=False
wrap=True
editor_btns = [{
    "name": "Run",
    "feather": "Play",
    "primary": True,
    "hasText": True,
    "showWithIcon": True,
    "commands": ["submit"],
    "style": {"bottom": "0.44rem", "right": "0.4rem"}
  }]
sample_python_code = '''# This is some sample code
a=2
if a > 1:
    print("The value is greater than 1")
'''

# code editor
response_dict = code_editor(sample_python_code,  height = height, lang=language, theme=theme, shortcuts=shortcuts, focus=focus, buttons=editor_btns, options={"wrap": True, "showLineNumber": True})

# show response dict
if len(response_dict['id']) != 0 and ( response_dict['type'] == "selection" or response_dict['type'] == "submit" ):
    # Capture the text part
    code_text = response_dict['text']
    st.code(code_text, language='python') #Captured the code parameter.```

Appologies! There is a typo in my previous response to you. It is showLineNumbers plural instead of showLineNumber . I will correct my previous comment! The list of options can be found here

1 Like

No worries! Thanks, now this makes it look better. I appreciate it!

Hi everyone!

Great news! I just released version 0.1.4 of streamlit-code-editor which comes with a couple highly requested features!

NEW response modes

One highly requested feature is the ability for code_editor to automatically update the streamlit script after changes are made to the code. I added a new argument to the code_editor function called response_mode that turns this feature on.

Note: To provide greater flexibility and different degrees of sensitivities, I decided to do this in a way that separates concerns.

response_mode accepts a single string or an array of strings. These strings must have one of the following values (listed in order from least sensitive to most):

  • "default" - old behavior. The response dictionary that is returned by code_editor() only gets updated when a response command gets executed (i.e. via key command or button click).

  • "blur" - a response is sent to streamlit script when the editor loses focus. So basically when you click anywhere in the app that is outside of the editor, then the response dictionary that is returned by the code_editor function gets updated with the latest info.

  • "debounce" - the code editor returns a response when a fixed amount of time has passed after the last change made to the contents of the editor. If another change is made before the time is up, then the timer is reset and restarted. This amount of time can be set using the debounceChangePeriod prop which defaults to 250 ms.

  • "select" the code editor returns a response when the text selection changes. This includes when the cursor is moved to a new location via touch or mouse. There is no debouncing in effect here so highlighting can often result in a lot of reruns in a short time.

If you want more than one option in effect, you can provide response_mode with an array of multiple values. For example, if you want “blur” and “debounce”, you can add response_mode=["blur", "debounce"] to the code_editor function. Note that "default" is always in effect so it does not need to be included in the array.

Tip: *It is highly recommend that you set allow_reset=True in code_editor if you plan to set response_mode to anything other than "default"

NEW response dictionary properties

I have added a very useful data to the response dictionary that is returned by code_editor. New properties include:

  • id - unique id for every single new response. This id is unique to each response regardles of content. Even if the content of the editor hasnt changed and its state is the same, if a new response is returned, it will have a different id value.

  • selected - the text that is highlighted/selected.

  • cursor - the position of the cursor in the editor.


To the community:

I want to thank you all for the feedback I received and for giving this custom component a shot. There are some very cool and interesting applications I have seen make use of this component!

There is still so much I want to add and improve in this project. Features that could enable AI text suggestion/completion for example. But unfortunately, my time is stretched thin these days and there is a lot to maintain. It doesn’t help that I am starting a new project every few weeks or so :sweat_smile:

One of the things I still need to do is update the docs! There is so much that can be improved as well. Imo, its way too wordy. I think things can be said more clearly and succinctly. Its also incomplete. The examples page could use more examples and the Reference page needs to be updated to include new additions (like the ones above) and so on.

If anyone wants to contribute to the project, this is one area that is in desperate need of attention so I (as well as others I’m sure) would very much appreciate any contributions you can make!

If you do want to contribute, I suggest doing so by forking the repository, then changing the streamlit script files in the docs folder. You can see the changes you make by running the 0_Getting_started.py script:

streamlit run 0_Getting_started.py

Note: Make sure you have streamlit-code-editor installed beforehand!

And when you have completed your additions/changes then you can submit a pull request to submit your changes!

2 Likes

Hello again!

I just finished another major update (version 0.1.20) containing improvements that have been in the works for a while.

The inner ace editor has now been updated to the latest version (latest ace-build version 1.34.2). This brings in several new features and options.

One feature I want to highlight is the ability to add ghost text at the end of the line containing the cursor. This means you can now add semi-transparent suggestion text inline. This has multiple uses. One very exciting use case is AI code completion. Using this ghost text feature in combination with the recently added response modes should allow you to do some of the things that AI coding assistants like copilot are able to do.

To add ghost text, just set the new ghost_text argument:

import streamlit as st
from code_editor import code_editor

response = code_editor(code_text, lang="python", ghost_text="Enter your code here")

TIP: Setting response_mode = ["blur", "debounce"] will cause the editor to rerun when the cursor leaves the editor (blur) or after a brief pause in typing. This should allow you to clear the ghost text when the user clicks out of the editor and it should allow you to offer new suggestions after every brief pause in typing.

The current key command for inserting the ghost text into the code is Ctrl+/. You can change this using the keybindings argument of code_editor

Another feature added to code_editor is the ability to provide your own custom autocompletion list. I got the impression that users sometimes find the default suggestions to be annoying and not very useful. This new feature should allow you to create your own custom list and either add your list to the existing one or replace the existing one with your list.

I am currently working on updating the docs so stay tuned for details on how to implement the new features!

3 Likes

Love this module! Good job. Debounce makes all the difference

1 Like

Hi @bouzidanas,

Awesome module. Thanks a lot for keeping it up. :raised_hands: I am currently working on implementing this component to run code on streamlit using the subprocess module and so far it works really well. However, I wonder if there is a way to debounce automatically after each run without having to modify the code already written in the editor? I find that it affects reruns caused by buttons that modify session_state variables, however, once you debounce by modifying the code written in the editor the problem goes away :grin:

if "PYTHON_INTERPRETER_HISTORY" not in st.session_state:
            st.session_state.PYTHON_INTERPRETER_HISTORY = {}
        if "selected_output" not in st.session_state:
            st.session_state.selected_output = None
        if "selected_output_button" not in st.session_state:
            st.session_state.selected_output_button = False

        def update_chat_history(role, input, output):
            index = len(st.session_state.PYTHON_INTERPRETER_HISTORY)
            st.session_state.PYTHON_INTERPRETER_HISTORY[index] = {
                "code_block": role,
                "items": [{"type": "code_input", "content": input},
                        {"type": "code_output", "content": output}]
            }
            st.session_state.selected_output = index


        def create_output_buttons():
            for index, item in st.session_state.PYTHON_INTERPRETER_HISTORY.items():
                if st.button(f"Output {index + 1}", key=f"button_{index}"):
                    st.session_state.selected_output = index
                    st.session_state.selected_output_button = True
                    # st.rerun()
        
        def output_container(index, message):
            with st.container(border=True):
                st.markdown(f"**Output {index + 1}:**")
                output_placeholder = st.empty()
                with output_placeholder.container():
                    for item in message["items"]:
                        if item["type"] == "code_input":
                            st.code(item["content"], language="python")
                        elif item["type"] == "code_output":
                            if 'matplotlib_figure' in item["content"]:
                                fig_data = base64.b64decode(item['content']['matplotlib_figure'])
                                fig = pickle.loads(fig_data)
                                st.pyplot(fig)
                            if 'plotly_figure' in item["content"]:
                                fig_data = item['content']['plotly_figure']
                                if isinstance(fig_data, str):
                                    fig_json = json.loads(fig_data)
                                else:
                                    fig_json = fig_data
                                fig = go.Figure(fig_json)
                                st.plotly_chart(fig, use_container_width=True)
                            if "user_prints" in item["content"]:    
                                st.write(item["content"]["user_prints"])

        def display_selected_output():
            logger.info(f"st.session_state.PYTHON_INTERPRETER_HISTORY: {st.session_state.PYTHON_INTERPRETER_HISTORY}")
            logger.info(f"st.session_state.selected_output: {st.session_state.selected_output}")
            if st.session_state.selected_output is not None and st.session_state.selected_output in st.session_state.PYTHON_INTERPRETER_HISTORY:
                selected_index = st.session_state.selected_output
                selected_message = st.session_state.PYTHON_INTERPRETER_HISTORY[selected_index]
                output_container(selected_index, selected_message)

        def display_chat_history():
            logger.info(f"st.session_state.PYTHON_INTERPRETER_HISTORY: {st.session_state.PYTHON_INTERPRETER_HISTORY}")
            logger.info(f"st.session_state.selected_output: {st.session_state.selected_output}")
            if st.session_state.PYTHON_INTERPRETER_HISTORY:
                latest_index = max(st.session_state.PYTHON_INTERPRETER_HISTORY.keys())
                message = st.session_state.PYTHON_INTERPRETER_HISTORY[latest_index]
                output_container(latest_index, message)
            else:
                st.write("No output history yet.")


        def execute_in_subprocess(code):
            with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as tmp:
                tmp.write(textwrap.dedent(code))
                tmp.flush()
                try:
                    python_code = textwrap.dedent('''
                        import json
                        import sys
                        import matplotlib.pyplot as plt
                        import plotly.graph_objects as go
                        import io
                        import base64
                        import pickle

                        def serialize(obj):
                            if isinstance(obj, (int, float, str, bool, type(None))):
                                return obj
                            return str(obj)

                        # Redirect stdout to capture print statements
                        import contextlib
                        user_output = io.StringIO()
                        with contextlib.redirect_stdout(user_output):
                            try:
                                with open("{0}", "r") as file:
                                    exec(file.read())
                                output = {{k: serialize(v) for k, v in locals().items() if not k.startswith('_') and k != 'user_output'}}

                                # Capture matplotlib figure if it exists
                                mpl_fig = plt.gcf()
                                if mpl_fig.get_axes():
                                    buf = io.BytesIO()
                                    pickle.dump(mpl_fig, buf)
                                    buf.seek(0)
                                    output['matplotlib_figure'] = base64.b64encode(buf.getvalue()).decode('utf-8')
                                    plt.close(mpl_fig)

                                # Capture Plotly figure if it exists
                                if 'fig' in locals() and isinstance(fig, go.Figure):
                                    output['plotly_figure'] = json.loads(fig.to_json())

                                output['user_prints'] = user_output.getvalue()

                            except Exception as e:
                                print(json.dumps({{"error": str(e)}}), file=sys.stderr)
                                sys.exit(1)

                        print(json.dumps({{"output": output}}))
                    '''.format(tmp.name))

                    with st.expander("Subprocess Code", expanded=False):
                        st.code(python_code, language="python")

                    result = subprocess.run(
                        ['python', '-c', python_code],
                        capture_output=True,
                        text=True,
                        check=True,
                        timeout=5
                    )

                    if result.returncode == 0:
                        try:
                            output = json.loads(result.stdout.strip())
                            return {
                                "status": "success",
                                "output": output["output"]
                            }
                        except json.JSONDecodeError:
                            return {
                                "status": "error",
                                "error": f"Invalid JSON output. Raw output: {result.stdout}"
                            }
                    else:
                        return {
                            "status": "error",
                            "error": f"Subprocess error: {result.stderr.strip()}"
                        }
                except subprocess.TimeoutExpired:
                    return {'error': "Execution timed out (5 seconds)"}
                finally:
                    os.unlink(tmp.name)


        col1, col2 = st.columns(2)

        with col1:
            # @st.experimental_fragment
            def streamlit_code_editor():
                response_dict = code_editor(code="",
                                            height=[19, 22],
                                            lang="python",
                                            theme="default",
                                            shortcuts="vscode",
                                            focus=True,
                                            buttons=[{
                                                "name": "Run",
                                                "feather": "Play",
                                                "primary": True,
                                                "hasText": True,
                                                "showWithIcon": True,
                                                "commands": ["submit"],
                                                "style": {"bottom": "0.44rem", "right": "0.4rem"}
                                            }], 
                                            options={
                                                "wrap": True, 
                                                "showLineNumbers": True
                                            },
                                            ghost_text="Type your code here",
                                            response_mode=["blur", "debounce"],
                                            allow_reset=True,
                                            key="code_editor"
                                            )

                # Process code execution only when submitted
                if response_dict['type'] == "submit":
                    code_text = response_dict['text']
                    result = execute_in_subprocess(code_text)

                    if result["status"] == "error":
                        update_chat_history("interpreter", code_text, result["error"])
                    else:
                        output = result["output"]
                        update_chat_history("interpreter", code_text, output)

                    st.session_state.selected_output_button = False
                    # st.rerun()

            streamlit_code_editor()

        # Move create_output_buttons outside of streamlit_code_editor
        create_output_buttons()

        with col2:
            with st.container(border=False):
                if not st.session_state.selected_output_button:
                    display_chat_history()
                else:
                    display_selected_output()

        logger.info(f"st.session_state.PYTHON_INTERPRETER_HISTORY: {st.session_state.PYTHON_INTERPRETER_HISTORY}")
        logger.info(f"st.session_state.selected_output: {st.session_state.selected_output}")

I think if the problem is clicking outside buttons that change session state, then the response_mode = ["blur"] is more suitable for this problem as the code_editor will rerun when you click any button outside of it and so you should get the same result as "debounce" without as many reruns. Might be a better choice than what you have working now.

I tried doing the change and it still does not decouples automatically. Both settings give me the exact results and I noticed that I have to click inside the editor and then somewhere outside to decouple the editor and avoid rerunning the code inside the editor when I click a button.
Doing a small change in the code base and rerunning streamlit does not decouple the editor as well. It seems that I might need to play around with boolian switches to avoid this behaviour or maybe use a st.button (“Run”) to the run the code instead of using the components run button.