aider/aider/gui.py
Paul Gauthier 0e5342fdb8 copy
2024-06-06 11:01:27 -07:00

533 lines
17 KiB
Python
Executable file

#!/usr/bin/env python
import os
import random
import sys
import streamlit as st
from aider.coders import Coder
from aider.dump import dump # noqa: F401
from aider.io import InputOutput
from aider.main import main as cli_main
from aider.scrape import Scraper
class CaptureIO(InputOutput):
lines = []
def tool_output(self, msg):
self.lines.append(msg)
def tool_error(self, msg):
self.lines.append(msg)
def get_captured_lines(self):
lines = self.lines
self.lines = []
return lines
def search(text=None):
results = []
for root, _, files in os.walk("aider"):
for file in files:
path = os.path.join(root, file)
if not text or text in path:
results.append(path)
# dump(results)
return results
# Keep state as a resource, which survives browser reloads (since Coder does too)
class State:
keys = set()
def init(self, key, val=None):
if key in self.keys:
return
self.keys.add(key)
setattr(self, key, val)
return True
@st.cache_resource
def get_state():
return State()
@st.cache_resource
def get_coder():
coder = cli_main(return_coder=True)
if not isinstance(coder, Coder):
raise ValueError(coder)
if not coder.repo:
raise ValueError("GUI can currently only be used inside a git repo")
io = CaptureIO(
pretty=False,
yes=True,
dry_run=coder.io.dry_run,
encoding=coder.io.encoding,
)
# coder.io = io # this breaks the input_history
coder.commands.io = io
return coder
class GUI:
prompt = None
prompt_as = "user"
last_undo_empty = None
recent_msgs_empty = None
web_content_empty = None
def announce(self):
lines = self.coder.get_announcements()
lines = " \n".join(lines)
return lines
def show_edit_info(self, edit):
commit_hash = edit.get("commit_hash")
commit_message = edit.get("commit_message")
diff = edit.get("diff")
fnames = edit.get("fnames")
if fnames:
fnames = sorted(fnames)
if not commit_hash and not fnames:
return
show_undo = False
res = ""
if commit_hash:
prefix = "aider: "
if commit_message.startswith(prefix):
commit_message = commit_message[len(prefix) :]
res += f"Commit `{commit_hash}`: {commit_message} \n"
if commit_hash == self.coder.last_aider_commit_hash:
show_undo = True
if fnames:
fnames = [f"`{fname}`" for fname in fnames]
fnames = ", ".join(fnames)
res += f"Applied edits to {fnames}."
if diff:
with st.expander(res):
st.code(diff, language="diff")
if show_undo:
self.add_undo(commit_hash)
else:
with st.container(border=True):
st.write(res)
if show_undo:
self.add_undo(commit_hash)
def add_undo(self, commit_hash):
if self.last_undo_empty:
self.last_undo_empty.empty()
self.last_undo_empty = st.empty()
undone = self.state.last_undone_commit_hash == commit_hash
if not undone:
with self.last_undo_empty:
if self.button(f"Undo commit `{commit_hash}`", key=f"undo_{commit_hash}"):
self.do_undo(commit_hash)
def do_sidebar(self):
with st.sidebar:
st.title("Aider")
# self.cmds_tab, self.settings_tab = st.tabs(["Commands", "Settings"])
# self.do_recommended_actions()
self.do_add_to_chat()
self.do_recent_msgs()
self.do_clear_chat_history()
# st.container(height=150, border=False)
# st.write("### Experimental")
st.warning(
"This browser version of aider is experimental. Please share feedback in [GitHub"
" issues](https://github.com/paul-gauthier/aider/issues)."
)
def do_settings_tab(self):
pass
def do_recommended_actions(self):
with st.expander("Recommended actions", expanded=True):
with st.popover("Create a git repo to track changes"):
st.write(
"Aider works best when your code is stored in a git repo. \n[See the FAQ"
" for more info](https://aider.chat/docs/git.html)"
)
self.button("Create git repo", key=random.random(), help="?")
with st.popover("Update your `.gitignore` file"):
st.write("It's best to keep aider's internal files out of your git repo.")
self.button("Add `.aider*` to `.gitignore`", key=random.random(), help="?")
def do_add_to_chat(self):
# with st.expander("Add to the chat", expanded=True):
self.do_add_files()
self.do_add_web_page()
def do_add_files(self):
fnames = st.multiselect(
"Add files to the chat",
self.coder.get_all_relative_files(),
default=self.state.initial_inchat_files,
placeholder="Files to edit",
disabled=self.prompt_pending(),
help=(
"Only add the files that need to be *edited* for the task you are working"
" on. Aider will pull in other relevant code to provide context to the LLM."
),
)
for fname in fnames:
if fname not in self.coder.get_inchat_relative_files():
self.coder.add_rel_fname(fname)
self.info(f"Added {fname} to the chat")
for fname in self.coder.get_inchat_relative_files():
if fname not in fnames:
self.coder.drop_rel_fname(fname)
self.info(f"Removed {fname} from the chat")
def do_add_web_page(self):
with st.popover("Add a web page to the chat"):
self.do_web()
def do_add_image(self):
with st.popover("Add image"):
st.markdown("Hello World 👋")
st.file_uploader("Image file", disabled=self.prompt_pending())
def do_run_shell(self):
with st.popover("Run shell commands, tests, etc"):
st.markdown(
"Run a shell command and optionally share the output with the LLM. This is"
" a great way to run your program or run tests and have the LLM fix bugs."
)
st.text_input("Command:")
st.radio(
"Share the command output with the LLM?",
[
"Review the output and decide whether to share",
"Automatically share the output on non-zero exit code (ie, if any tests fail)",
],
)
st.selectbox(
"Recent commands",
[
"my_app.py --doit",
"my_app.py --cleanup",
],
disabled=self.prompt_pending(),
)
def do_tokens_and_cost(self):
with st.expander("Tokens and costs", expanded=True):
pass
def do_show_token_usage(self):
with st.popover("Show token usage"):
st.write("hi")
def do_clear_chat_history(self):
text = "Saves tokens, reduces confusion"
if self.button("Clear chat history", help=text):
self.coder.done_messages = []
self.coder.cur_messages = []
self.info("Cleared chat history. Now the LLM can't see anything before this line.")
def do_show_metrics(self):
st.metric("Cost of last message send & reply", "$0.0019", help="foo")
st.metric("Cost to send next message", "$0.0013", help="foo")
st.metric("Total cost this session", "$0.22")
def do_git(self):
with st.expander("Git", expanded=False):
# st.button("Show last diff")
# st.button("Undo last commit")
self.button("Commit any pending changes")
with st.popover("Run git command"):
st.markdown("## Run git command")
st.text_input("git", value="git ")
self.button("Run")
st.selectbox(
"Recent git commands",
[
"git checkout -b experiment",
"git stash",
],
disabled=self.prompt_pending(),
)
def do_recent_msgs(self):
if not self.recent_msgs_empty:
self.recent_msgs_empty = st.empty()
if self.prompt_pending():
self.recent_msgs_empty.empty()
self.state.recent_msgs_num += 1
with self.recent_msgs_empty:
self.old_prompt = st.selectbox(
"Resend a recent chat message",
self.state.input_history,
placeholder="Choose a recent chat message",
# label_visibility="collapsed",
index=None,
key=f"recent_msgs_{self.state.recent_msgs_num}",
disabled=self.prompt_pending(),
)
if self.old_prompt:
self.prompt = self.old_prompt
def do_messages_container(self):
self.messages = st.container()
# stuff a bunch of vertical whitespace at the top
# to get all the chat text to the bottom
# self.messages.container(height=300, border=False)
with self.messages:
for msg in self.state.messages:
role = msg["role"]
if role == "edit":
self.show_edit_info(msg)
elif role == "info":
st.info(msg["content"])
elif role == "text":
text = msg["content"]
line = text.splitlines()[0]
with self.messages.expander(line):
st.text(text)
elif role in ("user", "assistant"):
with st.chat_message(role):
st.write(msg["content"])
# self.cost()
else:
st.dict(msg)
def initialize_state(self):
messages = [
dict(role="info", content=self.announce()),
dict(role="assistant", content="How can I help you?"),
]
self.state.init("messages", messages)
self.state.init("last_aider_commit_hash", self.coder.last_aider_commit_hash)
self.state.init("last_undone_commit_hash")
self.state.init("recent_msgs_num", 0)
self.state.init("web_content_num", 0)
self.state.init("prompt")
self.state.init("scraper")
self.state.init("initial_inchat_files", self.coder.get_inchat_relative_files())
if "input_history" not in self.state.keys:
input_history = list(self.coder.io.get_input_history())
seen = set()
input_history = [x for x in input_history if not (x in seen or seen.add(x))]
self.state.input_history = input_history
self.state.keys.add("input_history")
def button(self, args, **kwargs):
"Create a button, disabled if prompt pending"
# Force everything to be disabled if there is a prompt pending
if self.prompt_pending():
kwargs["disabled"] = True
return st.button(args, **kwargs)
def __init__(self):
self.coder = get_coder()
self.state = get_state()
# Force the coder to cooperate, regardless of cmd line args
self.coder.yield_stream = True
self.coder.stream = True
self.coder.pretty = False
self.initialize_state()
self.do_messages_container()
self.do_sidebar()
user_inp = st.chat_input("Say something")
if user_inp:
self.prompt = user_inp
if self.prompt_pending():
self.process_chat()
if not self.prompt:
return
self.state.prompt = self.prompt
if self.prompt_as == "user":
self.coder.io.add_to_input_history(self.prompt)
self.state.input_history.append(self.prompt)
if self.prompt_as:
self.state.messages.append({"role": self.prompt_as, "content": self.prompt})
if self.prompt_as == "user":
with self.messages.chat_message("user"):
st.write(self.prompt)
elif self.prompt_as == "text":
line = self.prompt.splitlines()[0]
line += "??"
with self.messages.expander(line):
st.text(self.prompt)
# re-render the UI for the prompt_pending state
st.rerun()
def prompt_pending(self):
return self.state.prompt is not None
def cost(self):
cost = random.random() * 0.003 + 0.001
st.caption(f"${cost:0.4f}")
def process_chat(self):
prompt = self.state.prompt
self.state.prompt = None
while prompt:
with self.messages.chat_message("assistant"):
res = st.write_stream(self.coder.run_stream(prompt))
self.state.messages.append({"role": "assistant", "content": res})
# self.cost()
if self.coder.reflected_message:
self.info(self.coder.reflected_message)
prompt = self.coder.reflected_message
with self.messages:
edit = dict(
role="edit",
fnames=self.coder.aider_edited_files,
)
if self.state.last_aider_commit_hash != self.coder.last_aider_commit_hash:
edit["commit_hash"] = self.coder.last_aider_commit_hash
edit["commit_message"] = self.coder.last_aider_commit_message
commits = f"{self.coder.last_aider_commit_hash}~1"
diff = self.coder.repo.diff_commits(
self.coder.pretty,
commits,
self.coder.last_aider_commit_hash,
)
edit["diff"] = diff
self.state.last_aider_commit_hash = self.coder.last_aider_commit_hash
self.state.messages.append(edit)
self.show_edit_info(edit)
# re-render the UI for the non-prompt_pending state
st.rerun()
def info(self, message, echo=True):
info = dict(role="info", content=message)
self.state.messages.append(info)
# We will render the tail of the messages array after this call
if echo:
self.messages.info(message)
def do_web(self):
st.markdown("Add the text content of a web page to the chat")
if not self.web_content_empty:
self.web_content_empty = st.empty()
if self.prompt_pending():
self.web_content_empty.empty()
self.state.web_content_num += 1
with self.web_content_empty:
self.web_content = st.text_input(
"URL",
placeholder="https://...",
key=f"web_content_{self.state.web_content_num}",
)
if not self.web_content:
return
url = self.web_content
if not self.state.scraper:
self.scraper = Scraper(print_error=self.info)
instructions = self.scraper.get_playwright_instructions()
if instructions:
self.info(instructions)
content = self.scraper.scrape(url) or ""
if content.strip():
content = f"{url}\n\n" + content
self.prompt = content
self.prompt_as = "text"
else:
self.info(f"No web content found for `{url}`.")
self.web_content = None
def do_undo(self, commit_hash):
self.last_undo_empty.empty()
if (
self.state.last_aider_commit_hash != commit_hash
or self.coder.last_aider_commit_hash != commit_hash
):
self.info(f"Commit `{commit_hash}` is not the latest commit.")
return
self.coder.commands.io.get_captured_lines()
reply = self.coder.commands.cmd_undo(None)
lines = self.coder.commands.io.get_captured_lines()
lines = "\n".join(lines)
lines = lines.splitlines()
lines = " \n".join(lines)
self.info(lines, echo=False)
self.state.last_undone_commit_hash = commit_hash
if reply:
self.prompt_as = None
self.prompt = reply
def gui_main():
st.set_page_config(
layout="wide",
page_title="Aider",
page_icon="https://aider.chat/assets/favicon-32x32.png",
menu_items={
"Get Help": "https://aider.chat/",
"Report a bug": "https://github.com/paul-gauthier/aider/issues",
"About": "# Aider\nAI pair programming in your browser.",
},
)
# config_options = st.config._config_options
# for key, value in config_options.items():
# print(f"{key}: {value.value}")
GUI()
if __name__ == "__main__":
status = gui_main()
sys.exit(status)