複数のAI CLIツールを並列で比較するPySide6ベースのデスクトップアプリケーション

複数のAI CLIツールを並列で比較するPySide6ベースのデスクトップアプリケーション

開発者が複数のAIコード生成CLIツール(例:Qwen Code、GitHub Copilot CLI、OpenCode、Gemini CLI)の中から最適なものを選定するのは容易ではありません。それぞれ個別に試すのは非効率的です。本稿では、PySide6 を用いて、ひとつのプロンプトを同時に複数のAI CLIに送信し、その応答をリアルタイムで並列表示できるデスクトップアプリケーションの構築方法を紹介します。

構築対象

Python と PySide6 で作成されたGUIアプリケーション。インストール済みのAI CLIを自動検出し、入力したプロンプトをすべてのツールに同時送信。各ツールからの出力を独立したパネルにストリーミング表示します。また、Markdown形式の出力はHTMLとしてレンダリング可能で、読みやすさを向上させます。

前提条件

  • Python 3.10以上
  • PySide6pip install PySide6
  • markdownpip install markdown
  • 以下のいずれかのAI CLIツールをインストール済みであること:
CLIツール名 インストールコマンド 公式ドキュメント
Qwen Code npm install -g @qwen-code/qwen-code github.com/QwenLM/qwen-code
GitHub Copilot CLI gh extension install github/gh-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

依存関係のインストール

requirements.txtファイルを作成し、以下の内容を記述してインストールします。

PySide6>=6.6.0
markdown>=3.5
pip install -r requirements.txt

CLIツールの設定情報の定義

各AI CLIの実行コマンド、表示名、カラースキームなどを辞書のリストとして定義します。これにより、実行時に動的にUIを生成できます。

import platform
import shutil

# Windows環境かどうかを判定
IS_WINDOWS = platform.system() == "Windows"

# Copilotの実際のバイナリパス(ラッパーを回避)
_REAL_COPILOT_BINARY = None

def _detect_copilot_binary():
    """CopilotのPowerShellラッパーではなく、実際のexeを検出"""
    path = shutil.which("copilot")
    if not path or path.lower().endswith(".exe"):
        return path
    # ラッパーのディレクトリを一時的にPATHから除外
    wrapper_dir = os.path.dirname(os.path.abspath(path))
    filtered_path = [p for p in os.environ.get("PATH", "").split(os.pathsep)
                     if os.path.normcase(os.path.abspath(p)) != os.path.normcase(wrapper_dir)]
    original_path = os.environ["PATH"]
    os.environ["PATH"] = os.pathsep.join(filtered_path)
    try:
        return shutil.which("copilot")  # .exeが見つかるはず
    finally:
        os.environ["PATH"] = original_path

# 初期化時にCopilotの真のバイナリを検出
_REAL_COPILOT_BINARY = _detect_copilot_binary()

# 各CLIの定義
TOOL_DEFINITIONS = [
    {
        "id": "qwen",
        "label": "通義千問",
        "command_builder": lambda prompt: ["qwen", prompt],
        "theme_color": "#4A9EEB",
        "install_command": "npm install -g @qwen-code/qwen-code",
        "documentation_url": "https://github.com/QwenLM/qwen-code"
    },
    {
        "id": "copilot",
        "label": "GitHub Copilot CLI",
        "command_builder": lambda prompt: (
            [_REAL_COPILOT_BINARY, "-p", prompt] 
            if _REAL_COPILOT_BINARY else ["copilot", "-p", prompt]
        ),
        "theme_color": "#9B6FE8",
        "install_command": "gh extension install github/gh-copilot",
        "documentation_url": "https://docs.github.com/en/copilot/github-copilot-in-the-cli"
    },
    {
        "id": "opencode",
        "label": "OpenCode",
        "command_builder": lambda prompt: ["opencode", "run", prompt],
        "theme_color": "#E8623A",
        "install_command": "npm install -g opencode-ai",
        "documentation_url": "https://opencode.ai"
    },
    {
        "id": "gemini",
        "label": "Gemini CLI",
        "command_builder": lambda prompt: ["gemini", "-p", prompt],
        "theme_color": "#34A853",
        "install_command": "npm install -g @google/gemini-cli",
        "documentation_url": "https://github.com/google-gemini/gemini-cli"
    }
]

Windows向けサブプロセス処理の調整

npm経由でインストールされたCLIはWindowsでは.cmd.batのラッパーとなるため、subprocess.Popenで直接起動できません。cmd /cを介して実行する必要があります。

import subprocess
import os

def create_subprocess_args(args):
    """Windowsで.cmd/.batラッパーを正しく実行するための引数を生成"""
    if IS_WINDOWS and not args[0].lower().endswith(('.exe', '.com')):
        return ['cmd', '/c'] + args
    return args

非同期処理による出力のストリーミング

ユーザーインターフェースのフリーズを防ぐため、各CLIの実行はQThread上で非同期に行います。ANSIエスケープシーケンスは除去され、リアルタイムでUIに送信されます。

import re
from PySide6.QtCore import QThread, Signal

# ANSIエスケープシーケンス除去用正規表現
ANSI_ESCAPE_PATTERN = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')

def remove_ansi_codes(text):
    """テキストからANSIエスケープコードを削除"""
    return ANSI_ESCAPE_PATTERN.sub('', text)

class AsyncToolRunner(QThread):
    """非同期でCLIツールを実行し、出力をストリーミングするワーカー"""
    output_received = Signal(str)
    execution_finished = Signal(bool, str)  # 成功フラグ、メッセージ

    def __init__(self, command_sequence):
        super().__init__()
        self.raw_command = command_sequence
        self.process_handle = None
        self.is_cancelled = False

    def cancel_execution(self):
        """実行中のプロセスをキャンセル"""
        self.is_cancelled = True
        if self.process_handle and self.process_handle.poll() is None:
            self.process_handle.kill()

    def run(self):
        try:
            final_args = create_subprocess_args(self.raw_command)
            self.process_handle = subprocess.Popen(
                final_args,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                text=True,
                encoding='utf-8',
                errors='replace'
            )

            while True:
                line_output = self.process_handle.stdout.readline()
                if not line_output:  # 出力終了
                    break
                if self.is_cancelled:
                    self.process_handle.kill()
                    self.execution_finished.emit(False, "ユーザーにより中断されました")
                    return
                clean_line = remove_ansi_codes(line_output)
                self.output_received.emit(clean_line)

            self.process_handle.wait()
            exit_code = self.process_handle.returncode
            success = (exit_code == 0)
            message = "" if success else f"プロセスが異常終了: 終了コード {exit_code}"
            self.execution_finished.emit(success, message)

        except FileNotFoundError:
            self.execution_finished.emit(False, f"コマンドが見つかりません: {self.raw_command[0]}")
        except Exception as error:
            self.execution_finished.emit(False, str(error))

結果表示パネルの実装

各AIツールの結果を表示するパネル。プレーンテキストと、MarkdownをHTMLに変換したビューの切り替えが可能です。

import markdown
from PySide6.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit, QWebEngineView
from PySide6.QtCore import Qt

# Markdownレンダリング用CSS(ダークテーマ対応)
MARKDOWN_STYLESHEET = """
<style>
body { color: #e0e0e0; background: #1e1e1e; font-family: 'Segoe UI', sans-serif; padding: 12px; }
code { background: #2d2d2d; padding: 2px 4px; border-radius: 3px; }
pre { background: #2d2d2d; padding: 10px; overflow: auto; border-radius: 4px; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
th, td { border: 1px solid #444; padding: 8px; text-align: left; }
th { background: #333; }
</style>
"""

def render_markdown_to_html(content):
    """Markdown文字列をHTMLに変換"""
    extensions = ['fenced_code', 'tables', 'nl2br']
    html_body = markdown.markdown(content, extensions=extensions)
    return f"<html><head>{MARKDOWN_STYLESHEET}</head><body>{html_body}</body></html>"

class ToolResultPanel(QFrame):
    """単一のAIツールの結果を表示するUIコンポーネント"""
    
    def __init__(self, tool_info, is_available):
        super().__init__()
        self.tool_config = tool_info
        self.is_tool_present = is_available
        self.current_content = ""
        self.display_in_rendered_mode = False
        self.worker_thread = None
        self._setup_ui()

    def _setup_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(8, 8, 8, 8)

        # ヘッダー部
        header_layout = QHBoxLayout()
        title_label = QLabel(self.tool_config["label"])
        title_label.setStyleSheet(f"color: {self.tool_config['theme_color']}; font-weight: bold;")
        header_layout.addWidget(title_label)

        self.switch_button = QPushButton("⟳ レンダリング")
        self.switch_button.setFixedWidth(100)
        self.switch_button.clicked.connect(self._toggle_view_mode)
        header_layout.addWidget(self.switch_button)

        self.status_indicator = QLabel("● 未実行")
        self.status_indicator.setStyleSheet("color: #aaa;")
        header_layout.addWidget(self.status_indicator)
        header_layout.addStretch()

        layout.addLayout(header_layout)

        # コンテンツ表示領域
        self.plain_text_view = QTextEdit()
        self.plain_text_view.setReadOnly(True)
        self.plain_text_view.setStyleSheet("background: #252526; color: #d4d4d4; font-family: 'Consolas', monospace;")
        layout.addWidget(self.plain_text_view)

        self.html_render_view = QWebEngineView()
        self.html_render_view.setMinimumHeight(200)
        layout.addWidget(self.html_render_view)
        self.html_render_view.hide()

        if not self.is_tool_present:
            self._show_installation_guide()

    def _toggle_view_mode(self):
        self.display_in_rendered_mode = not self.display_in_rendered_mode
        if self.display_in_rendered_mode:
            self.switch_button.setText("✎ テキスト")
            rendered_html = render_markdown_to_html(self.current_content)
            self.html_render_view.setHtml(rendered_html)
            self.plain_text_view.hide()
            self.html_render_view.show()
        else:
            self.switch_button.setText("⟳ レンダリング")
            self.html_render_view.hide()
            self.plain_text_view.show()

    def _set_status_message(self, message, color_hex):
        self.status_indicator.setText(message)
        self.status_indicator.setStyleSheet(f"color: {color_hex};")

    def _show_installation_guide(self):
        info = self.tool_config
        guide_html = f"""
        <div style="color:#e0e0e0; font-family:'Segoe UI',sans-serif; padding:16px; background:#2a2a2a; border-radius:6px;">
          <p style="font-size:16px; font-weight:bold; color:{info['theme_color']};">{info['label']}</p>
          <p style="color:#ff6b6b; font-size:13px;">⚠ システムにインストールされていません</p>
          <p style="font-size:12px; color:#aaa; margin-top:16px;">インストール方法:</p>
          <p style="margin-top:4px; background:#333; padding:8px; border-radius:4px; font-family:monospace; font-size:13px;">
            {info['install_command']}
          </p>
          <p style="font-size:12px; color:#aaa; margin-top:12px;">ドキュメント:</p>
          <a href="{info['documentation_url']}" style="color:#4A9EEB; text-decoration:none; font-size:13px;">{info['documentation_url']}</a>
        </div>
        """
        self.plain_text_view.setHtml(guide_html)
        self.switch_button.setEnabled(False)

    def start_query(self, prompt_text):
        if not self.is_tool_present:
            return

        self._reset_panel_state()
        command_line = self.tool_config["command_builder"](prompt_text)
        
        self.worker_thread = AsyncToolRunner(command_line)
        self.worker_thread.output_received.connect(self._append_output_chunk)
        self.worker_thread.execution_finished.connect(self._on_execution_complete)
        self.worker_thread.start()

    def _reset_panel_state(self):
        self.current_content = ""
        self.plain_text_view.clear()
        self._set_status_message("● 実行中...", "#FFD700")
        if self.worker_thread:
            self.worker_thread.quit()

    def _append_output_chunk(self, text_segment):
        self.current_content += text_segment
        self.plain_text_view.insertPlainText(text_segment)
        if self.display_in_rendered_mode:
            self.html_render_view.setHtml(render_markdown_to_html(self.current_content))

    def _on_execution_complete(self, success, message):
        status_text = "● 完了" if success else "● 失敗"
        status_color = "#32CD32" if success else "#FF6B6B"
        self._set_status_message(status_text, status_color)

メインウィンドウの構築

全ツールのパネルを横並びに配置し、下部にプロンプト入力欄を設けます。Ctrl+Enterでも送信可能にします。

from PySide6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QSplitter
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QKeyEvent

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("AI CLI ツール比較アプリ")
        self.resize(1400, 800)
        self.panels_list = []

        central_widget = QWidget()
        main_layout = QVBoxLayout(central_widget)
        self.setCentralWidget(central_widget)

        # パネル群を分割可能ウィジェットで配置
        splitter = QSplitter(Qt.Horizontal)
        main_layout.addWidget(splitter, 1)

        # 各ツールのパネルを生成
        for tool_def in TOOL_DEFINITIONS:
            is_installed = shutil.which(tool_def["id"]) is not None
            panel = ToolResultPanel(tool_def, is_installed)
            splitter.addWidget(panel)
            self.panels_list.append(panel)

        # プロンプト入力エリア
        input_container = QHBoxLayout()
        self.prompt_editor = QTextEdit()
        self.prompt_editor.setMaximumHeight(100)
        self.prompt_editor.setPlaceholderText("ここに比較したいプロンプトを入力してください...")
        input_container.addWidget(self.prompt_editor)

        send_button = QPushButton("送信")
        send_button.setFixedWidth(100)
        send_button.clicked.connect(self._execute_all_queries)
        input_container.addWidget(send_button)

        main_layout.addLayout(input_container)

        # Ctrl+Enterで送信を可能にする
        self.prompt_editor.installEventFilter(self)

    def eventFilter(self, source, event):
        if (source is self.prompt_editor and 
            event.type() == QEvent.KeyPress):
            key_event = event
            if (key_event.key() == Qt.Key.Key_Return and 
                key_event.modifiers() & Qt.KeyboardModifier.ControlModifier):
                self._execute_all_queries()
                return True
        return super().eventFilter(source, event)

    def _execute_all_queries(self):
        prompt = self.prompt_editor.toPlainText().strip()
        if not prompt:
            return
        self.prompt_editor.clear()
        for panel in self.panels_list:
            panel.start_query(prompt)

まとめ

本アプリケーションにより、複数のAIコーディングアシスタントの出力を瞬時に比較できるようになります。特に、コード生成能力やフォーマットの違いを視覚的に把握するのに非常に有効です。拡張性も考慮して設計されており、新しいCLIツールの追加はTOOL_DEFINITIONSへの登録のみで可能です。

タグ: PySide6 Python Qt マルチスレッド subprocess

8月5日 22:38 投稿