DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

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.

AI CLI comparison

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
Enter fullscreen mode Exit fullscreen mode
pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

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",
    },
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Key design decisions:

  • stdin=subprocess.DEVNULL prevents CLI tools from blocking on interactive prompts.
  • stderr=subprocess.STDOUT merges 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)
Enter fullscreen mode Exit fullscreen mode

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>"""
Enter fullscreen mode Exit fullscreen mode

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;">&#9888; 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Source Code

https://github.com/yushulx/multi-ai-cli-comparison

Top comments (0)