複数の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以上
- PySide6:
pip install PySide6 - markdown:
pip 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への登録のみで可能です。