User Feedback
by mrexodia
Verified
import os
import sys
import json
import psutil
import argparse
import subprocess
import threading
from typing import Optional, TypedDict
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QCheckBox, QTextEdit, QGroupBox
)
from PySide6.QtCore import Qt, Signal, QObject, QTimer, QSettings
from PySide6.QtGui import QTextCursor, QIcon, QKeyEvent, QFont, QFontDatabase, QPalette, QColor
class FeedbackResult(TypedDict):
command_logs: str
user_feedback: str
class FeedbackConfig(TypedDict):
run_command: str
execute_automatically: bool = False
def set_dark_title_bar(widget: QWidget, dark_title_bar: bool) -> None:
# Ensure we're on Windows
if sys.platform != "win32":
return
from ctypes import windll, c_uint32, byref
# Get Windows build number
build_number = sys.getwindowsversion().build
if build_number < 17763: # Windows 10 1809 minimum
return
# Check if the widget's property already matches the setting
dark_prop = widget.property("DarkTitleBar")
if dark_prop is not None and dark_prop == dark_title_bar:
return
# Set the property (True if dark_title_bar != 0, False otherwise)
widget.setProperty("DarkTitleBar", dark_title_bar)
# Load dwmapi.dll and call DwmSetWindowAttribute
dwmapi = windll.dwmapi
hwnd = widget.winId() # Get the window handle
attribute = 20 if build_number >= 18985 else 19 # Use newer attribute for newer builds
c_dark_title_bar = c_uint32(dark_title_bar) # Convert to C-compatible uint32
dwmapi.DwmSetWindowAttribute(hwnd, attribute, byref(c_dark_title_bar), 4)
# HACK: Create a 1x1 pixel frameless window to force redraw
temp_widget = QWidget(None, Qt.FramelessWindowHint)
temp_widget.resize(1, 1)
temp_widget.move(widget.pos())
temp_widget.show()
temp_widget.deleteLater() # Safe deletion in Qt event loop
def get_dark_mode_palette(app: QApplication):
darkPalette = app.palette()
darkPalette.setColor(QPalette.Window, QColor(53, 53, 53))
darkPalette.setColor(QPalette.WindowText, Qt.white)
darkPalette.setColor(QPalette.Disabled, QPalette.WindowText, QColor(127, 127, 127))
darkPalette.setColor(QPalette.Base, QColor(42, 42, 42))
darkPalette.setColor(QPalette.AlternateBase, QColor(66, 66, 66))
darkPalette.setColor(QPalette.ToolTipBase, QColor(53, 53, 53))
darkPalette.setColor(QPalette.ToolTipText, Qt.white)
darkPalette.setColor(QPalette.Text, Qt.white)
darkPalette.setColor(QPalette.Disabled, QPalette.Text, QColor(127, 127, 127))
darkPalette.setColor(QPalette.Dark, QColor(35, 35, 35))
darkPalette.setColor(QPalette.Shadow, QColor(20, 20, 20))
darkPalette.setColor(QPalette.Button, QColor(53, 53, 53))
darkPalette.setColor(QPalette.ButtonText, Qt.white)
darkPalette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(127, 127, 127))
darkPalette.setColor(QPalette.BrightText, Qt.red)
darkPalette.setColor(QPalette.Link, QColor(42, 130, 218))
darkPalette.setColor(QPalette.Highlight, QColor(42, 130, 218))
darkPalette.setColor(QPalette.Disabled, QPalette.Highlight, QColor(80, 80, 80))
darkPalette.setColor(QPalette.HighlightedText, Qt.white)
darkPalette.setColor(QPalette.Disabled, QPalette.HighlightedText, QColor(127, 127, 127))
darkPalette.setColor(QPalette.PlaceholderText, QColor(127, 127, 127))
return darkPalette
def kill_tree(process: subprocess.Popen):
killed: list[psutil.Process] = []
parent = psutil.Process(process.pid)
for proc in parent.children(recursive=True):
try:
proc.kill()
killed.append(proc)
except psutil.Error:
pass
try:
parent.kill()
except psutil.Error:
pass
killed.append(parent)
# Terminate any remaining processes
for proc in killed:
try:
if proc.is_running():
proc.terminate()
except psutil.Error:
pass
def get_user_environment() -> dict[str, str]:
if sys.platform != "win32":
return os.environ.copy()
import ctypes
from ctypes import wintypes
# Load required DLLs
advapi32 = ctypes.WinDLL("advapi32")
userenv = ctypes.WinDLL("userenv")
kernel32 = ctypes.WinDLL("kernel32")
# Constants
TOKEN_QUERY = 0x0008
# Function prototypes
OpenProcessToken = advapi32.OpenProcessToken
OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
OpenProcessToken.restype = wintypes.BOOL
CreateEnvironmentBlock = userenv.CreateEnvironmentBlock
CreateEnvironmentBlock.argtypes = [ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE, wintypes.BOOL]
CreateEnvironmentBlock.restype = wintypes.BOOL
DestroyEnvironmentBlock = userenv.DestroyEnvironmentBlock
DestroyEnvironmentBlock.argtypes = [wintypes.LPVOID]
DestroyEnvironmentBlock.restype = wintypes.BOOL
GetCurrentProcess = kernel32.GetCurrentProcess
GetCurrentProcess.argtypes = []
GetCurrentProcess.restype = wintypes.HANDLE
CloseHandle = kernel32.CloseHandle
CloseHandle.argtypes = [wintypes.HANDLE]
CloseHandle.restype = wintypes.BOOL
# Get process token
token = wintypes.HANDLE()
if not OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)):
raise RuntimeError("Failed to open process token")
try:
# Create environment block
environment = ctypes.c_void_p()
if not CreateEnvironmentBlock(ctypes.byref(environment), token, False):
raise RuntimeError("Failed to create environment block")
try:
# Convert environment block to list of strings
result = {}
env_ptr = ctypes.cast(environment, ctypes.POINTER(ctypes.c_wchar))
offset = 0
while True:
# Get string at current offset
current_string = ""
while env_ptr[offset] != "\0":
current_string += env_ptr[offset]
offset += 1
# Skip null terminator
offset += 1
# Break if we hit double null terminator
if not current_string:
break
equal_index = current_string.index("=")
if equal_index == -1:
continue
key = current_string[:equal_index]
value = current_string[equal_index + 1:]
result[key] = value
return result
finally:
DestroyEnvironmentBlock(environment)
finally:
CloseHandle(token)
class FeedbackTextEdit(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
def keyPressEvent(self, event: QKeyEvent):
if event.key() == Qt.Key_Return and event.modifiers() == Qt.ControlModifier:
# Find the parent FeedbackUI instance and call submit
parent = self.parent()
while parent and not isinstance(parent, FeedbackUI):
parent = parent.parent()
if parent:
parent._submit_feedback()
else:
super().keyPressEvent(event)
class LogSignals(QObject):
append_log = Signal(str)
class FeedbackUI(QMainWindow):
def __init__(self, project_directory: str, prompt: str):
super().__init__()
self.project_directory = project_directory
self.prompt = prompt
self.config_path = os.path.join(project_directory, ".user-feedback.json")
self.config = self._load_config()
self.process: Optional[subprocess.Popen] = None
self.log_buffer = []
self.feedback_result = None
self.log_signals = LogSignals()
self.log_signals.append_log.connect(self._append_log)
self.setWindowTitle("User Feedback")
self.setWindowIcon(QIcon("icons/feedback.png"))
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
self._create_ui()
# Restore window geometry
settings = QSettings("UserFeedback", "MainWindow")
geometry = settings.value("geometry")
if geometry:
self.restoreGeometry(geometry)
else:
# Default size and center on screen
self.resize(800, 600)
screen = QApplication.primaryScreen().geometry()
x = (screen.width() - 800) // 2
y = (screen.height() - 600) // 2
self.move(x, y)
# Restore window state
state = settings.value("windowState")
if state:
self.restoreState(state)
set_dark_title_bar(self, True)
if self.config.get("execute_automatically", False):
self._run_command()
def _load_config(self) -> FeedbackConfig:
try:
if os.path.exists(self.config_path):
with open(self.config_path, "r") as f:
return FeedbackConfig(**json.load(f))
except Exception:
pass
return FeedbackConfig(run_command="", execute_automatically=False)
def _save_config(self):
with open(self.config_path, "w") as f:
json.dump(self.config, f, indent=2)
def _format_windows_path(self, path: str) -> str:
if sys.platform == "win32":
# Convert forward slashes to backslashes
path = path.replace("/", "\\")
# Capitalize drive letter if path starts with x:\
if len(path) >= 2 and path[1] == ":" and path[0].isalpha():
path = path[0].upper() + path[1:]
return path
def _create_ui(self):
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QVBoxLayout(central_widget)
# Command section
command_group = QGroupBox("Command")
command_layout = QVBoxLayout(command_group)
# Working directory label
formatted_path = self._format_windows_path(self.project_directory)
working_dir_label = QLabel(f"Working directory: {formatted_path}")
command_layout.addWidget(working_dir_label)
# Command input row
command_input_layout = QHBoxLayout()
self.command_entry = QLineEdit()
self.command_entry.setText(self.config["run_command"])
self.command_entry.returnPressed.connect(self._run_command)
self.command_entry.textChanged.connect(self._update_config)
self.run_button = QPushButton("&Run")
self.run_button.clicked.connect(self._run_command)
command_input_layout.addWidget(self.command_entry)
command_input_layout.addWidget(self.run_button)
command_layout.addLayout(command_input_layout)
# Auto-execute and save config row
auto_layout = QHBoxLayout()
self.auto_check = QCheckBox("Execute automatically")
self.auto_check.setChecked(self.config.get("execute_automatically", False))
self.auto_check.stateChanged.connect(self._update_config)
save_button = QPushButton("&Save Configuration")
save_button.clicked.connect(self._save_config)
auto_layout.addWidget(self.auto_check)
auto_layout.addStretch()
auto_layout.addWidget(save_button)
command_layout.addLayout(auto_layout)
layout.addWidget(command_group)
# Feedback section with fixed size
feedback_group = QGroupBox("Feedback")
feedback_layout = QVBoxLayout(feedback_group)
feedback_group.setFixedHeight(150)
self.feedback_text = FeedbackTextEdit()
self.feedback_text.setMinimumHeight(60)
self.feedback_text.setPlaceholderText(self.prompt)
submit_button = QPushButton("Submit &Feedback (Ctrl+Enter)")
submit_button.clicked.connect(self._submit_feedback)
feedback_layout.addWidget(self.feedback_text)
feedback_layout.addWidget(submit_button)
# Console section (takes remaining space)
console_group = QGroupBox("Console")
console_layout = QVBoxLayout(console_group)
console_group.setMinimumHeight(200)
# Log text area
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
font = QFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
font.setPointSize(9)
self.log_text.setFont(font)
console_layout.addWidget(self.log_text)
# Clear button
button_layout = QHBoxLayout()
self.clear_button = QPushButton("&Clear")
self.clear_button.clicked.connect(self.clear_logs)
button_layout.addStretch()
button_layout.addWidget(self.clear_button)
console_layout.addLayout(button_layout)
# Add widgets in reverse order (feedback at bottom)
layout.addWidget(console_group, stretch=1) # Takes all remaining space
layout.addWidget(feedback_group) # Fixed size, no stretch
def _update_config(self):
self.config = {
"run_command": self.command_entry.text(),
"execute_automatically": self.auto_check.isChecked()
}
def _append_log(self, text: str):
self.log_buffer.append(text)
self.log_text.append(text.rstrip())
cursor = self.log_text.textCursor()
cursor.movePosition(QTextCursor.End)
self.log_text.setTextCursor(cursor)
def _check_process_status(self):
if self.process and self.process.poll() is not None:
# Process has terminated
exit_code = self.process.poll()
self._append_log(f"\nProcess exited with code {exit_code}\n")
self.run_button.setText("&Run")
self.process = None
self.activateWindow()
self.feedback_text.setFocus()
def _run_command(self):
if self.process:
kill_tree(self.process)
self.process = None
self.run_button.setText("&Run")
return
# Clear the log buffer but keep UI logs visible
self.log_buffer = []
command = self.command_entry.text()
if not command:
self._append_log("Please enter a command to run\n")
return
self._append_log(f"$ {command}\n")
self.run_button.setText("Sto&p")
try:
self.process = subprocess.Popen(
command,
shell=True,
cwd=self.project_directory,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=get_user_environment(),
text=True,
bufsize=1,
encoding="utf-8",
errors="ignore",
close_fds=True,
)
def read_output(pipe):
for line in iter(pipe.readline, ""):
self.log_signals.append_log.emit(line)
threading.Thread(
target=read_output,
args=(self.process.stdout,),
daemon=True
).start()
threading.Thread(
target=read_output,
args=(self.process.stderr,),
daemon=True
).start()
# Start process status checking
self.status_timer = QTimer()
self.status_timer.timeout.connect(self._check_process_status)
self.status_timer.start(100) # Check every 100ms
except Exception as e:
self._append_log(f"Error running command: {str(e)}\n")
self.run_button.setText("&Run")
def _submit_feedback(self):
self.feedback_result = FeedbackResult(
logs="".join(self.log_buffer),
user_feedback=self.feedback_text.toPlainText().strip(),
)
self.close()
def clear_logs(self):
self.log_buffer = []
self.log_text.clear()
def closeEvent(self, event):
# Save window geometry and state
settings = QSettings("UserFeedback", "MainWindow")
settings.setValue("geometry", self.saveGeometry())
settings.setValue("windowState", self.saveState())
if self.process:
kill_tree(self.process)
super().closeEvent(event)
def run(self) -> FeedbackResult:
self.show()
QApplication.instance().exec()
if self.process:
kill_tree(self.process)
if not self.feedback_result:
return FeedbackResult(logs="".join(self.log_buffer), user_feedback="")
return self.feedback_result
def feedback_ui(project_directory: str, prompt: str, output_file: Optional[str] = None) -> Optional[FeedbackResult]:
app = QApplication.instance() or QApplication()
app.setPalette(get_dark_mode_palette(app))
app.setStyle("Fusion")
ui = FeedbackUI(project_directory, prompt)
result = ui.run()
if output_file and result:
# Ensure the directory exists
os.makedirs(os.path.dirname(output_file) if os.path.dirname(output_file) else ".", exist_ok=True)
# Save the result to the output file
with open(output_file, "w") as f:
json.dump(result, f)
return None
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run the feedback UI")
parser.add_argument("--project-directory", default=os.getcwd(), help="The project directory to run the command in")
parser.add_argument("--prompt", default="I implemented the changes you requested.", help="The prompt to show to the user")
parser.add_argument("--output-file", help="Path to save the feedback result as JSON")
args = parser.parse_args()
result = feedback_ui(args.project_directory, args.prompt, args.output_file)
if result:
print(f"\nLogs collected: \n{result['logs']}")
print(f"\nFeedback received:\n{result['user_feedback']}")
sys.exit(0)