tools/smol_tools/demo_tkinter.py (534 lines of code) (raw):
import tkinter as tk
from tkmacosx import Button
import time
import threading
import queue
from pynput import keyboard
from smol_tools.summarizer import SmolSummarizer
from smol_tools.rewriter import SmolRewriter
from pynput.keyboard import Key, Controller
import pyperclip
from smol_tools.agent import SmolToolAgent
from smol_tools.chatter import SmolChatter
from smol_tools.titler import SmolTitler
import os
import getpass
class TextPopupApp:
def __init__(self, root):
self.root = root
self.root.withdraw() # Start with the window hidden
self.last_text = ""
self.active_popups = []
self.last_summary = ""
# Initialize tools
self.summarizer = SmolSummarizer()
self.rewriter = SmolRewriter()
self.titler = SmolTitler()
self.agent = SmolToolAgent()
self.chatter = SmolChatter()
self.keyboard_controller = Controller()
# Replace the keyboard listener with GlobalHotKeys
self.keyboard_listener = keyboard.GlobalHotKeys({
"<F1>": lambda: self.show_draft_input(
self.root.winfo_x(),
self.root.winfo_y(),
self.root.winfo_width()
),
"<F2>": self.generate_summary_from_selected_text,
"<F5>": self.show_chat_window,
"<F10>": self.show_agent_input,
})
self.keyboard_listener.start()
self.username = getpass.getuser() # Get system username
def generate_summary_from_selected_text(self):
selected_text = self.get_selected_text()
if selected_text:
# Directly generate summary instead of showing confirmation popup
self.generate_summary_direct(selected_text)
# New method to directly show summary window
def generate_summary_direct(self, text):
summary_popup = tk.Toplevel(self.root)
summary_popup.withdraw() # Hide the window initially
self.active_popups.append(summary_popup)
summary_popup.title("Summary Chat")
summary_popup.configure(bg='#f6f8fa')
# Set minimum window size
summary_popup.minsize(600, 600)
# Main container
container = tk.Frame(summary_popup, bg='#f6f8fa')
container.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
# Chat display area (top)
chat_frame = tk.Frame(
container,
bg='white',
highlightbackground='#e1e4e8',
highlightthickness=1,
bd=0,
relief=tk.FLAT
)
chat_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Add chat display with scrollbar
chat_display = tk.Text(
chat_frame,
wrap=tk.WORD,
borderwidth=0,
highlightthickness=0,
bg='white',
fg='#24292e',
font=('Segoe UI', 12),
padx=15,
pady=12
)
scrollbar = tk.Scrollbar(chat_frame, command=chat_display.yview)
chat_display.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
chat_display.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Configure text tags
chat_display.tag_configure("assistant_name", foreground="#E57373", font=('Segoe UI', 12, 'bold'))
chat_display.tag_configure("user_name", foreground="#7986CB", font=('Segoe UI', 12, 'bold'))
chat_display.config(state='disabled')
# Input area (bottom)
input_container = tk.Frame(
container,
bg='white',
highlightbackground='#e1e4e8',
highlightthickness=1,
bd=0,
relief=tk.FLAT
)
input_container.pack(fill=tk.X)
# Text input
chat_input = tk.Text(
input_container,
height=3,
wrap=tk.WORD,
borderwidth=0,
highlightthickness=0,
bg='white',
fg='#24292e',
font=('Segoe UI', 12),
padx=15,
pady=12,
insertwidth=2, # Width of cursor
insertbackground='#0066FF', # Color of cursor matching our theme
insertofftime=500, # Cursor blink off time in milliseconds
insertontime=500 # Cursor blink on time in milliseconds
)
chat_input.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Send button
send_btn = Button(
input_container,
text="Ask Question",
command=lambda: self.process_summary_question(
text, chat_input.get("1.0", "end-1c").strip(),
chat_display, chat_input
),
font=('Segoe UI', 12),
bg='#0066FF',
fg='white',
activebackground='#0052CC',
activeforeground='white',
borderless=True,
focuscolor='',
height=32,
padx=15
)
send_btn.pack(side=tk.RIGHT, padx=15, pady=8)
# Bind Enter key
chat_input.bind("<Return>", lambda e: [
self.process_summary_question(
text, chat_input.get("1.0", "end-1c").strip(),
chat_display, chat_input
),
"break"
][1])
# Display initial summary request
preview = text[:100] + "..." if len(text) > 100 else text
self.update_summary_chat(chat_display, self.username, f"Please summarize this text: {preview}")
# Position window
summary_popup.update_idletasks()
popup_width = 600
popup_height = 600
# Get mouse position and screen dimensions
mouse_x = self.root.winfo_pointerx()
mouse_y = self.root.winfo_pointery()
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
# Calculate position
x = min(max(mouse_x - popup_width//2, 0), screen_width - popup_width)
y = min(max(mouse_y - popup_height//2, 0), screen_height - popup_height)
summary_popup.geometry(f"{popup_width}x{popup_height}+{x}+{y}")
summary_popup.deiconify()
def summarize(input_text):
try:
# First message from the model
self.root.after(0, lambda: self.update_summary_chat(
chat_display, self.summarizer.name, ""))
current_response = ""
for output in self.summarizer.process(input_text):
# Only send the new part of the response
if output.startswith(current_response):
new_text = output[len(current_response):]
if new_text: # Only update if there's new text
current_response = output
self.root.after(0, lambda t=new_text: chat_display.config(state='normal') or
chat_display.insert("end-1c", t) or
chat_display.config(state='disabled'))
except Exception as e:
print(e)
threading.Thread(target=lambda: summarize(text), daemon=True).start()
def update_summary_chat(self, chat_display: tk.Text, sender: str, message: str):
"""Update the summary chat display with new message"""
chat_display.config(state='normal')
# Add the message with appropriate styling
chat_display.insert(tk.END, "\n") # Add spacing
chat_display.insert(tk.END, sender,
"assistant_name" if sender == self.summarizer.name else "user_name")
chat_display.insert(tk.END, f": {message}")
chat_display.see(tk.END)
chat_display.config(state='disabled')
def process_summary_question(self, original_text: str, question: str,
chat_display: tk.Text, chat_input: tk.Text):
"""Process a follow-up question about the summarized text"""
if not question.strip():
return
# Clear input
chat_input.delete("1.0", tk.END)
# Display user question
self.update_summary_chat(chat_display, self.username, question)
def process_question():
try:
# First message from the model
self.root.after(0, lambda: self.update_summary_chat(
chat_display, self.summarizer.name, ""))
current_response = ""
for output in self.summarizer.process(original_text, question=question):
# Only send the new part of the response
if output.startswith(current_response):
new_text = output[len(current_response):]
if new_text: # Only update if there's new text
current_response = output
self.root.after(0, lambda t=new_text: chat_display.config(state='normal') or
chat_display.insert("end-1c", t) or
chat_display.config(state='disabled'))
except Exception as e:
print(e)
threading.Thread(target=process_question, daemon=True).start()
def get_selected_text(self):
# Copy selected text to clipboard
with self.keyboard_controller.pressed(Key.cmd):
self.keyboard_controller.tap('c')
# Small delay to ensure clipboard is updated
time.sleep(0.1)
# Get text from clipboard
return pyperclip.paste()
def destroy_active_popups(self):
# Destroy all active popups
for popup in self.active_popups:
try:
popup.destroy()
except:
pass # Popup might already be destroyed
self.active_popups = []
def show_draft_input(self, summary_x, summary_y, summary_width):
draft_popup = tk.Toplevel(self.root)
draft_popup.withdraw() # Hide initially
self.active_popups.append(draft_popup)
draft_popup.title("Draft Reply")
draft_popup.configure(bg='#f6f8fa')
# Create frame for the two columns
columns_frame = tk.Frame(draft_popup, bg='#f6f8fa')
columns_frame.pack(expand=True, fill='both', padx=20, pady=20)
# Calculate required height based on summary content
num_lines = len(self.last_summary.split('\n'))
line_height = max(num_lines * 1.5, 15)
widget_height = min(line_height, 40)
# Column 1: Draft Input
input_frame = tk.Frame(
columns_frame,
bg='white',
highlightbackground='#e1e4e8',
highlightthickness=1,
bd=0,
relief=tk.FLAT
)
input_frame.pack(side=tk.LEFT, padx=5, fill='both', expand=True)
tk.Label(input_frame, text="Your Reply", bg='white', fg='#24292e', font=('Segoe UI', 12)).pack(padx=15, pady=(12,0), anchor='w')
text_input = tk.Text(
input_frame,
height=widget_height,
width=30,
wrap=tk.WORD,
borderwidth=0,
highlightthickness=0,
selectbackground='#e1e4e8',
selectforeground='#24292e',
insertwidth=2, # Width of cursor
insertbackground='#0066FF', # Color of cursor matching our theme
insertofftime=500, # Cursor blink off time in milliseconds
insertontime=500 # Cursor blink on time in milliseconds
)
text_input.pack(fill='both', expand=True, padx=15, pady=12)
text_input.config(bg='white', fg='#24292e', font=('Segoe UI', 12))
# Column 2: Improved Text
improved_frame = tk.Frame(
columns_frame,
bg='white',
highlightbackground='#e1e4e8',
highlightthickness=1,
bd=0,
relief=tk.FLAT
)
improved_frame.pack(side=tk.LEFT, padx=5, fill='both', expand=True)
tk.Label(improved_frame, text="Improved Reply", bg='white', fg='#24292e', font=('Segoe UI', 12)).pack(padx=15, pady=(12,0), anchor='w')
improved_text = tk.Text(
improved_frame,
height=widget_height,
width=30,
wrap=tk.WORD,
borderwidth=0,
highlightthickness=0,
selectbackground='#e1e4e8',
selectforeground='#24292e'
)
improved_text.pack(fill='both', expand=True, padx=15, pady=12)
improved_text.config(state='disabled', bg='white', fg='#586069', font=('Segoe UI', 12))
# Add Copy button using tkmacosx
copy_btn = Button(
improved_frame,
text="Copy",
command=lambda: pyperclip.copy(improved_text.get("1.0", "end-1c")),
font=('Segoe UI', 14),
bg='#0066FF',
fg='white',
activebackground='#0052CC',
activeforeground='white',
borderless=True,
focuscolor='',
padx=20,
pady=8,
cursor='hand2'
)
copy_btn.pack(pady=(0, 12))
# Add "Smol Improvement?" button using tkmacosx
improve_btn = Button(
input_frame,
text="Smol Improvement?",
command=lambda: self.generate_improved_text(
text_input.get("1.0", "end-1c"),
improved_text),
font=('Segoe UI', 14),
bg='#0066FF',
fg='white',
activebackground='#0052CC',
activeforeground='white',
borderless=True,
focuscolor='',
padx=20,
pady=8,
cursor='hand2'
)
improve_btn.pack(pady=(0, 12))
# Position window relative to the summary window's position
draft_popup.update_idletasks()
screen_width = draft_popup.winfo_screenwidth()
screen_height = draft_popup.winfo_screenheight()
popup_width = draft_popup.winfo_width()
if popup_width == 1:
popup_width = 800
popup_height = draft_popup.winfo_height()
# Calculate center point of the summary window
summary_center_x = summary_x + summary_width//2
# Center the new window on the same point, but ensure it stays on screen
new_x = max(min(summary_center_x - popup_width//2, screen_width - popup_width), 0)
# Position vertically based on screen space available
if summary_y > screen_height / 2:
new_y = max(summary_y - popup_height - 10, 0) # 10px gap above summary
else:
new_y = min(summary_y + 10, screen_height - popup_height) # 10px gap below summary
draft_popup.geometry(f"+{new_x}+{new_y}")
draft_popup.deiconify()
def generate_improved_text(self, text, improved_text_widget):
# Get reference to the improve button
improve_btn = improved_text_widget.master.master.children['!frame2'].children['!button']
# Disable the button and change text to show processing
improve_btn.configure(
state='disabled',
text="Generating...",
bg='#A8A8A8', # Grayed out color
)
# Update the improve function
improved_text_widget.config(state='normal')
improved_text_widget.delete("1.0", tk.END)
improved_text_widget.insert("1.0", "Generating improvement...")
improved_text_widget.config(state='disabled')
def improve(input_text):
try:
for output in self.rewriter.process(input_text):
self.root.after(0, lambda t=output: self.update_improved_text(improved_text_widget, t))
# Re-enable button and restore original state after generation is complete
self.root.after(0, lambda: improve_btn.config(
state='normal',
text="Copy",
bg='#0066FF'
))
except Exception as e:
# Make sure to re-enable button even if there's an error
self.root.after(0, lambda: improve_btn.config(
state='normal',
text="Copy",
bg='#0066FF'
))
raise e
threading.Thread(target=lambda: improve(text), daemon=True).start()
def update_improved_text(self, text_widget, new_text):
text_widget.config(state='normal')
text_widget.delete("1.0", tk.END)
text_widget.insert("1.0", new_text)
text_widget.config(state='disabled')
def show_agent_input(self):
# Create new popup for agent input
agent_popup = tk.Toplevel(self.root)
self.active_popups.append(agent_popup)
agent_popup.title("SmolAgent")
# Create input area
input_frame = tk.Frame(agent_popup)
input_frame.pack(padx=10, pady=5, fill='both', expand=True)
tk.Label(input_frame, text="What would you like me to do?").pack()
text_input = tk.Text(input_frame, height=4, width=50, wrap=tk.WORD)
text_input.pack(pady=5)
# Create output area
output_frame = tk.Frame(agent_popup)
output_frame.pack(padx=10, pady=5, fill='both', expand=True)
tk.Label(output_frame, text="Response:").pack()
output_text = tk.Text(output_frame, height=8, width=50, wrap=tk.WORD)
output_text.pack(pady=5)
output_text.config(state='disabled')
def process_agent_request():
query = text_input.get("1.0", "end-1c")
output_text.config(state='normal')
output_text.delete("1.0", tk.END)
output_text.insert("1.0", "Processing request...\n")
output_text.config(state='disabled')
def run_agent():
full_response = []
for response in self.agent.process(query):
full_response.append(response)
self.root.after(0, lambda t="\n".join(full_response): self.update_agent_output(output_text, t))
threading.Thread(target=run_agent, daemon=True).start()
# Add Submit button
submit_btn = tk.Button(agent_popup, text="Submit", command=process_agent_request)
submit_btn.pack(pady=5)
# Position window
agent_popup.update_idletasks()
screen_width = agent_popup.winfo_screenwidth()
screen_height = agent_popup.winfo_screenheight()
popup_width = agent_popup.winfo_width()
popup_height = agent_popup.winfo_height()
x = (screen_width - popup_width) // 2
y = (screen_height - popup_height) // 2
agent_popup.geometry(f"+{x}+{y}")
def update_agent_output(self, text_widget, new_text):
text_widget.config(state='normal')
text_widget.delete("1.0", tk.END)
text_widget.insert("1.0", new_text)
text_widget.config(state='disabled')
def show_chat_window(self):
chat_window = tk.Toplevel(self.root)
self.active_popups.append(chat_window)
chat_window.title("SmolChat")
# Configure the chat window to be resizable
chat_window.geometry("800x800")
chat_window.minsize(600, 600)
# Create split view with history panel
history_panel = tk.Frame(chat_window, width=200, padx=5, pady=10)
history_panel.pack(side=tk.LEFT, fill=tk.Y)
history_panel.pack_propagate(False) # Maintain width
# Create main chat area
main_frame = tk.Frame(chat_window, padx=10, pady=10)
main_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Create chat display first
chat_display = tk.Text(main_frame, wrap=tk.WORD)
chat_display.pack(fill=tk.BOTH, expand=True)
chat_display.config(state='disabled')
# Add input field and send button
input_frame = tk.Frame(main_frame)
input_frame.pack(fill=tk.X, pady=(10, 0))
chat_input = tk.Text(input_frame, height=3, wrap=tk.WORD)
chat_input.pack(side=tk.LEFT, fill=tk.X, expand=True)
send_btn = tk.Button(input_frame, text="Send",
command=lambda: self.process_chat_message(
chat_input.get("1.0", "end-1c").strip(),
chat_display))
send_btn.pack(side=tk.RIGHT, padx=(10, 0))
# Bind Enter key to send message
chat_input.bind("<Return>", lambda e: [
self.process_chat_message(
chat_input.get("1.0", "end-1c").strip(),
chat_display),
"break" # Prevent the default newline behavior
][1])
# Now add the New Chat button (after chat_display is created)
new_chat_btn = tk.Button(history_panel, text="New Chat",
command=lambda: self.start_new_chat(chat_display))
new_chat_btn.pack(fill=tk.X, pady=(0, 10))
# Add listbox for chat history
history_label = tk.Label(history_panel, text="Previous Chats")
history_label.pack()
chat_listbox = tk.Listbox(history_panel, height=20)
chat_listbox.pack(fill=tk.BOTH, expand=True)
# Get and sort chats by modification time (newest first)
saved_chats = self.chatter.get_saved_chats()
sorted_chats = sorted(
saved_chats,
key=lambda x: os.path.getmtime(os.path.join("saved_chats", f"chat_{x}.json")),
reverse=True
)
# Populate chat history with sorted chats
for chat_id in sorted_chats:
chat_listbox.insert(tk.END, chat_id)
# Bind selection event
chat_listbox.bind('<<ListboxSelect>>',
lambda e: self.load_selected_chat(chat_listbox, chat_display))
# Add scrollbar to listbox
history_scrollbar = tk.Scrollbar(history_panel, command=chat_listbox.yview)
history_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
chat_listbox.config(yscrollcommand=history_scrollbar.set)
# Store references to UI elements that need to be disabled during chat
self.chat_controls = {
'listbox': chat_listbox,
'new_chat_btn': new_chat_btn
}
# Add text tags with softer colors
chat_display.tag_configure("assistant_name", foreground="#E57373") # Soft red
chat_display.tag_configure("user_name", foreground="#7986CB") # Soft blue
def load_selected_chat(self, listbox: tk.Listbox, chat_display: tk.Text):
selection = listbox.curselection()
if selection:
chat_id = listbox.get(selection[0])
self.chatter.load_chat(chat_id)
self.display_chat_history(chat_display)
def start_new_chat(self, chat_display):
# Only save the current chat if it has been modified since loading
if self.chatter.has_current_chat() and self.chatter.is_chat_modified():
# Get full chat history as a single string
chat_history = "\n".join([f"{msg.role}: {msg.content}"
for msg in self.chatter.get_chat_history()])
# If we're continuing an existing chat, use its ID
current_chat_id = self.chatter.get_current_chat_id()
if current_chat_id:
# Save using existing ID
self.chatter.save_current_chat(current_chat_id, overwrite=True)
else:
# Generate new title for new chat
summary = ""
for chunk in self.titler.process(chat_history):
summary = chunk
summary_title = summary[:50].strip().replace("/", "-").replace("\\", "-")
self.chatter.save_current_chat(summary_title, overwrite=True)
# Start new chat
self.chatter.start_new_chat()
# Clear and update display
self.display_chat_history(chat_display)
# Update the chat history listbox with sorted chats
listbox = self.chat_controls['listbox']
listbox.delete(0, tk.END)
saved_chats = self.chatter.get_saved_chats()
sorted_chats = sorted(
saved_chats,
key=lambda x: os.path.getmtime(os.path.join("saved_chats", f"chat_{x}.json")),
reverse=True
)
for chat_id in sorted_chats:
listbox.insert(tk.END, chat_id)
def process_chat_message(self, message: str, chat_display: tk.Text):
if not message.strip(): # Skip empty messages
return
# Disable chat controls while processing
self.chat_controls['listbox'].config(state='disabled')
self.chat_controls['new_chat_btn'].config(state='disabled')
chat_display.config(state='normal')
# Add extra newline before user message for spacing
chat_display.insert(tk.END, "") # Start new line
chat_display.insert(tk.END, self.username, "user_name") # Add colored username
chat_display.insert(tk.END, f": {message}\n") # Add message
chat_display.insert(tk.END, "\n") # Add spacing
chat_display.insert(tk.END, self.chatter.name, "assistant_name") # Add colored AI name
chat_display.insert(tk.END, ": ") # Add separator
# Clear the input field (get its reference from chat_display's master)
input_frame = chat_display.master.children['!frame']
chat_input = input_frame.children['!text']
chat_input.delete("1.0", tk.END)
# Initialize an empty string to store the full response
self.current_response = ""
chat_display.see(tk.END)
chat_display.config(state='disabled')
def chat_response():
try:
for chunk in self.chatter.process(message):
# Only send the new part of the response
if chunk.startswith(self.current_response):
new_text = chunk[len(self.current_response):]
if new_text: # Only update if there's new text
self.current_response = chunk
self.root.after(0, lambda t=new_text: self.update_chat_display(chat_display, t))
self.root.after(0, lambda t="\n\n": self.update_chat_display(chat_display, t))
finally:
# Re-enable chat controls after response is complete
self.root.after(0, self.enable_chat_controls)
threading.Thread(target=chat_response, daemon=True).start()
def enable_chat_controls(self):
"""Re-enable chat controls after response is complete"""
self.chat_controls['listbox'].config(state='normal')
self.chat_controls['new_chat_btn'].config(state='normal')
def update_chat_display(self, chat_display: tk.Text, new_text: str):
chat_display.config(state='normal')
chat_display.insert(tk.END, new_text)
chat_display.see(tk.END)
chat_display.config(state='disabled')
def display_chat_history(self, chat_display: tk.Text):
chat_display.config(state='normal')
chat_display.delete("1.0", tk.END)
# Configure text tags with softer colors
chat_display.tag_configure("assistant_name", foreground="#E57373") # Soft red
chat_display.tag_configure("user_name", foreground="#7986CB") # Soft blue
for message in self.chatter.get_chat_history():
if message.role == "user":
chat_display.insert(tk.END, "\n") # Add spacing
chat_display.insert(tk.END, self.username, "user_name") # Change "You" to username
chat_display.insert(tk.END, f": {message.content}\n")
else:
chat_display.insert(tk.END, "\n") # Add spacing
chat_display.insert(tk.END, self.chatter.name, "assistant_name")
chat_display.insert(tk.END, f": {message.content}\n")
chat_display.config(state='disabled')
chat_display.see(tk.END)
# Run the app
root = tk.Tk()
# Set default font size for all tkinter widgets
default_font = ('Segoe UI', 14) # Changed from TkDefaultFont to Segoe UI
root.option_add("*Font", default_font)
root.option_add("*Entry.Font", default_font)
root.option_add("*Text.Font", default_font)
root.option_add("*Button.Font", default_font)
root.option_add("*Label.Font", default_font)
app = TextPopupApp(root)
root.mainloop()