Compare AI CLI Responses Side by Side with a Python PySide6 Desktop App
Choosing between AI coding assistants is hard when you can only test one at a time. Developers who have Qwen Code, GitHub Copilot CLI, OpenCode, and Gemini CLI installed need a fast way to send the same prompt to all of them and read the results in parallel. This project builds a Python desktop app with PySide6 that does exactly that — one prompt in, four streaming response panels out.
What you’ll build: A PySide6 desktop GUI that dynamically detects installed AI CLI tools, sends a single prompt to all of them concurrently, and streams each response into its own panel with a Markdown/rendered-HTML toggle for easy reading.

Prerequisites
- Python 3.10+
- PySide6 (
pip install PySide6) - markdown (
pip install markdown) - At least one of the following AI CLI tools installed and authenticated:
| CLI | Install command | Documentation |
|---|---|---|
| Qwen Code | npm install -g @qwen-code/qwen-code |
github.com/QwenLM/qwen-code |
| GitHub Copilot CLI | npm install -g @github/copilot |
docs.github.com |
| OpenCode | npm install -g opencode-ai |
opencode.ai |
| Gemini CLI | npm install -g @google/gemini-cli |
github.com/google-gemini/gemini-cli |
Install Dependencies
Create a requirements.txt with the two Python dependencies and install them:
PySide6>=6.6.0
markdown>=3.5
pip install -r requirements.txt
Define the CLI Tool Registry
Each AI CLI has its own binary name, non-interactive invocation flags, and brand color. The app stores these as a list of dictionaries so panels can be generated dynamically at startup.
CLI_DEFS = [
{
"id": "qwen",
"name": "Qwen Code",
"cmd": lambda p: ["qwen", p],
"color": "#4A9EEB",
"download_url": "https://github.com/QwenLM/qwen-code",
"install_hint": "npm install -g @qwen-code/qwen-code",
},
{
"id": "copilot",
"name": "GitHub Copilot CLI",
"cmd": lambda p: [_REAL_COPILOT, "-p", p] if _REAL_COPILOT else ["copilot", "-p", p],
"color": "#9B6FE8",
"download_url": "https://docs.github.com/en/copilot/github-copilot-in-the-cli",
"install_hint": "gh extension install github/gh-copilot",
},
{
"id": "opencode",
"name": "OpenCode",
"cmd": lambda p: ["opencode", "run", p],
"color": "#E8623A",
"download_url": "https://opencode.ai",
"install_hint": "npm install -g opencode-ai",
},
{
"id": "gemini",
"name": "Gemini CLI",
"cmd": lambda p: ["gemini", "-p", p],
"color": "#34A853",
"download_url": "https://github.com/google-gemini/gemini-cli",
"install_hint": "npm install -g @google/gemini-cli",
},
]
Each CLI’s non-interactive invocation syntax differs:
| CLI | Command pattern |
|---|---|
| Qwen Code | qwen "<prompt>" |
| GitHub Copilot CLI | copilot -p "<prompt>" |
| OpenCode | opencode run "<prompt>" |
| Gemini CLI | gemini -p "<prompt>" |
Handle Windows Subprocess
On Windows, npm-installed CLI tools are .CMD wrapper scripts that subprocess.Popen cannot spawn directly — they need to be executed through cmd /c. The build_cmd helper handles this transparently, while skipping the wrapper for real .exe binaries:
_IS_WINDOWS = platform.system() == "Windows"
def build_cmd(args: list[str]) -> list[str]:
"""On Windows .CMD/.BAT scripts cannot be spawned directly; wrap with cmd /c.
If the binary is already a .exe, no wrapping is needed."""
if _IS_WINDOWS and not args[0].lower().endswith(".exe"):
return ["cmd", "/c"] + args
return args
GitHub Copilot CLI has an additional complication: the VS Code extension installs a PowerShell wrapper (copilot.ps1) that performs an interactive version check using Read-Host. When stdin is /dev/null, this causes the process to exit immediately without running the query. The _find_real_copilot function resolves the actual copilot.exe binary by temporarily removing the wrapper’s directory from PATH:
def _find_real_copilot() -> str | None:
"""Find the real copilot binary, skipping the VS Code PS1/BAT wrapper."""
wrapper_path = shutil.which("copilot")
if not wrapper_path:
return None
if wrapper_path.lower().endswith(".exe"):
return wrapper_path
wrapper_dir = os.path.dirname(os.path.abspath(wrapper_path))
filtered = [p for p in os.environ.get("PATH", "").split(os.pathsep)
if os.path.normcase(os.path.abspath(p)) != os.path.normcase(wrapper_dir)]
old_path = os.environ["PATH"]
os.environ["PATH"] = os.pathsep.join(filtered)
try:
real = shutil.which("copilot")
finally:
os.environ["PATH"] = old_path
return real
Stream CLI Output with a Background QThread Worker
Each CLI runs in its own QThread to keep the UI responsive. The CLIWorker class spawns a subprocess, reads stdout line by line, strips ANSI escape codes, and emits each chunk as a Qt signal:
_ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
class CLIWorker(QThread):
output_chunk = Signal(str)
finished = Signal(bool, str)
def __init__(self, cmd: list[str]):
super().__init__()
self._cmd = cmd
self._cancelled = False
self._process: subprocess.Popen | None = None
def cancel(self):
self._cancelled = True
if self._process and self._process.poll() is None:
self._process.kill()
def run(self):
try:
self._process = subprocess.Popen(
self._cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
)
for line in iter(self._process.stdout.readline, ""):
if self._cancelled:
self._process.kill()
self.finished.emit(False, "Cancelled")
return
self.output_chunk.emit(strip_ansi(line))
self._process.stdout.close()
self._process.wait()
rc = self._process.returncode
if rc == 0:
self.finished.emit(True, "")
else:
self.finished.emit(False, f"Process exited with code {rc}")
except FileNotFoundError:
self.finished.emit(False, f"Command not found: {self._cmd[0]}")
except Exception as exc:
self.finished.emit(False, str(exc))
Key design decisions:
stdin=subprocess.DEVNULLprevents CLI tools from blocking on interactive prompts.stderr=subprocess.STDOUTmerges error output into the panel so nothing is silently lost.- Line-by-line iteration (
readline) enables real-time streaming rather than waiting for the process to finish.
Build per-CLI Response Panels with Markdown Rendering
Each CLIPanel is a QFrame containing a header (CLI name, toggle button, status indicator) and two stacked content areas — a monospace raw-text view and a rendered HTML view. The toggle button switches between them:
def _on_toggle(self, checked: bool):
self._rendered_mode = checked
if checked:
self._toggle_btn.setText("✎ Markdown")
self.render_area.setHtml(markdown_to_html(self._raw_text))
self.text_area.setVisible(False)
self.render_area.setVisible(True)
else:
self._toggle_btn.setText("⟳ Render")
self.render_area.setVisible(False)
self.text_area.setVisible(True)
The Markdown-to-HTML conversion uses the Python markdown library with extensions for fenced code blocks, tables, and line breaks, wrapped in a dark-theme CSS stylesheet:
_MD_EXTENSIONS = ["fenced_code", "tables", "nl2br", "sane_lists"]
def markdown_to_html(text: str) -> str:
body = md_lib.markdown(text, extensions=_MD_EXTENSIONS)
return f"""<!DOCTYPE html><html><head><meta charset='utf-8'>{_MD_CSS}</head>
<body>{body}</body></html>"""
When a CLI is not installed, the panel displays a styled info card with a clickable download URL and suggested install command instead of an empty text area:
def _show_download_info(self):
self._set_status("● Not installed", "#f44336")
url = self.cli_def["download_url"]
hint = self.cli_def["install_hint"]
name = self.cli_def["name"]
color = self.cli_def["color"]
html = f"""
<div style="color:#e0e0e0; font-family:'Segoe UI',sans-serif; padding:16px;">
<p style="font-size:16px; font-weight:bold; color:{color};">{name}</p>
<p style="color:#f44336; font-size:13px;">⚠ Not installed on this system</p>
<p style="font-size:12px; color:#aaa; margin-top:16px;">Download / Documentation:</p>
<p style="margin-top:4px;">
<a href="{url}" style="color:#4A9EEB; font-size:13px;">{url}</a>
</p>
</div>
"""
self.text_area.setHtml(html)
Assemble the Main Window and Prompt Bar
The MainWindow dynamically checks for installed CLIs using shutil.which, creates a CLIPanel for each one, and arranges them in a horizontal QSplitter. The prompt input area at the bottom supports both a Send button and a Ctrl+Enter keyboard shortcut:
# ── CLI panels ───────────────────────────────────────────────────────
splitter = QSplitter(Qt.Orientation.Horizontal)
available_count = 0
for cli_def in CLI_DEFS:
avail = shutil.which(cli_def["id"]) is not None
panel = CLIPanel(cli_def, avail)
splitter.addWidget(panel)
self._panels.append(panel)
if avail:
available_count += 1
When the user clicks Send, the prompt text is dispatched to every installed panel concurrently:
def _send_prompt(self):
prompt = self._prompt_input.toPlainText().strip()
if not prompt:
return
self._prompt_input.clear()
for panel in self._panels:
panel.start_query(prompt)
The Ctrl+Enter shortcut is implemented via a QEvent filter on the prompt input:
def eventFilter(self, obj, event):
if obj is self._prompt_input and event.type() == QEvent.Type.KeyPress:
key_ev: QKeyEvent = event
ctrl = Qt.KeyboardModifier.ControlModifier
if (
key_ev.key() == Qt.Key.Key_Return
and key_ev.modifiers() & ctrl
):
self._send_prompt()
return True
return super().eventFilter(obj, event)