from __future__ import annotations from functools import partial from PySide6.QtCore import Qt, QTimer from PySide6.QtWidgets import ( QApplication, QFrame, QHBoxLayout, QLabel, QLayout, QMainWindow, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QTextEdit, QVBoxLayout, QWidget, ) from clfgui.kernel import Job, JobSnapshot, LocalAgentKernel, Phase, SQLiteStatusStore from .paths import qstandard_db_path STATUS_LABELS = { "running": "Läuft", "waiting_decision": "Human-Gate", "awaiting_approval": "Ergebnisfreigabe", "approved": "Freigegeben", "rejected": "Abgelehnt", "blocked": "Blockiert", "pending": "Ausstehend", "waiting": "Wartet", "completed": "Fertig", "failed": "Fehler", } PHASE_LABELS = { "plan": "Plan", "research": "Research", "code": "Code", "test": "Test", "verify": "Verify", "result": "Ergebnis", } class MainWindow(QMainWindow): def __init__(self, kernel: LocalAgentKernel | None = None) -> None: super().__init__() self.kernel = kernel or LocalAgentKernel(SQLiteStatusStore(qstandard_db_path())) self.active_job_id: int | None = None self._selected_decision_id: int | None = None self._selected_decision_option: str | None = None self.setWindowTitle("CLFGUI") self.resize(1360, 820) self.setMinimumSize(1120, 700) self._build_ui() self._apply_style() self.refresh() self._timer = QTimer(self) self._timer.timeout.connect(self.refresh) self._timer.start(1000) def _build_ui(self) -> None: root = QWidget() root.setObjectName("appRoot") root_layout = QHBoxLayout(root) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) root_layout.addWidget(self._build_sidebar()) root_layout.addWidget(self._build_workspace(), 1) self.setCentralWidget(root) def _build_sidebar(self) -> QWidget: sidebar = QFrame() sidebar.setObjectName("sidebar") sidebar.setFixedWidth(342) layout = QVBoxLayout(sidebar) layout.setContentsMargins(22, 22, 22, 22) layout.setSpacing(18) title = QLabel("Owner Cockpit") title.setObjectName("appTitle") self.status_badge = QLabel("Bereit") self.status_badge.setObjectName("statusBadge") self.status_badge.setProperty("state", "idle") self.status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) self.intent_input = QTextEdit() self.intent_input.setObjectName("intentInput") self.intent_input.setAcceptRichText(False) self.intent_input.setPlaceholderText("Ich will ...") self.intent_input.setFixedHeight(116) self.submit_button = QPushButton("Auftrag starten") self.submit_button.setObjectName("primaryButton") self.submit_button.clicked.connect(self._submit_intent) jobs_title = QLabel("Aufträge") jobs_title.setObjectName("sidebarSection") self.jobs_content = QWidget() self.jobs_content.setObjectName("jobsContent") self.jobs_layout = QVBoxLayout(self.jobs_content) self.jobs_layout.setContentsMargins(0, 0, 0, 0) self.jobs_layout.setSpacing(8) jobs_scroll = QScrollArea() jobs_scroll.setObjectName("jobsScroll") jobs_scroll.setWidgetResizable(True) jobs_scroll.setFrameShape(QFrame.Shape.NoFrame) jobs_scroll.setWidget(self.jobs_content) layout.addWidget(title) layout.addWidget(self.status_badge) layout.addWidget(self.intent_input) layout.addWidget(self.submit_button) layout.addSpacing(6) layout.addWidget(jobs_title) layout.addWidget(jobs_scroll, 1) return sidebar def _build_workspace(self) -> QWidget: workspace = QWidget() workspace.setObjectName("workspace") layout = QVBoxLayout(workspace) layout.setContentsMargins(24, 22, 24, 22) layout.setSpacing(18) top = QHBoxLayout() top.setSpacing(16) title_group = QVBoxLayout() title_group.setSpacing(4) page_title = QLabel("Aktiver Auftrag") page_title.setObjectName("pageTitle") self.header_context = QLabel("Kein Auftrag aktiv.") self.header_context.setObjectName("headerContext") self.header_context.setWordWrap(True) title_group.addWidget(page_title) title_group.addWidget(self.header_context) self.plan_status = QLabel("Human-Gates: keine offen") self.plan_status.setObjectName("topMeta") top.addLayout(title_group, 1) top.addWidget(self.plan_status, 0, Qt.AlignmentFlag.AlignTop) body = QHBoxLayout() body.setSpacing(18) body.addWidget(self._build_plan_panel(), 1) body.addWidget(self._build_action_panel()) layout.addLayout(top) layout.addLayout(body, 1) return workspace def _build_plan_panel(self) -> QWidget: panel = self._panel("planPanel") layout = QVBoxLayout(panel) layout.setContentsMargins(20, 18, 20, 20) layout.setSpacing(14) layout.addWidget(self._section_title("Laufender Plan")) self.phase_content = QWidget() self.phase_content.setObjectName("phaseContent") self.phase_layout = QVBoxLayout(self.phase_content) self.phase_layout.setContentsMargins(0, 0, 0, 0) self.phase_layout.setSpacing(10) phase_scroll = QScrollArea() phase_scroll.setObjectName("phaseScroll") phase_scroll.setWidgetResizable(True) phase_scroll.setFrameShape(QFrame.Shape.NoFrame) phase_scroll.setWidget(self.phase_content) layout.addWidget(phase_scroll, 1) return panel def _build_action_panel(self) -> QWidget: panel = QWidget() panel.setObjectName("actionColumn") panel.setFixedWidth(402) layout = QVBoxLayout(panel) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(18) layout.addWidget(self._build_decision_panel(), 1) layout.addWidget(self._build_result_panel(), 1) return panel def _build_decision_panel(self) -> QWidget: panel = self._panel("decisionPanel") layout = QVBoxLayout(panel) layout.setContentsMargins(20, 18, 20, 20) layout.setSpacing(14) layout.addWidget(self._section_title("Entscheidung")) self.decision_content = QWidget() self.decision_content_layout = QVBoxLayout(self.decision_content) self.decision_content_layout.setContentsMargins(0, 0, 0, 0) self.decision_content_layout.setSpacing(12) layout.addWidget(self.decision_content, 1) return panel def _build_result_panel(self) -> QWidget: panel = self._panel("resultPanel") layout = QVBoxLayout(panel) layout.setContentsMargins(20, 18, 20, 20) layout.setSpacing(14) layout.addWidget(self._section_title("Ergebnis")) self.result_text = QTextEdit() self.result_text.setObjectName("resultText") self.result_text.setReadOnly(True) self.result_text.setAcceptRichText(False) self.result_text.setPlaceholderText("Noch kein Ergebnis vorhanden.") button_row = QHBoxLayout() button_row.setSpacing(10) self.approve_button = QPushButton("Freigeben") self.approve_button.setObjectName("primaryButton") self.approve_button.clicked.connect(partial(self._approve_result, True)) self.reject_button = QPushButton("Ablehnen") self.reject_button.setObjectName("secondaryButton") self.reject_button.clicked.connect(partial(self._approve_result, False)) button_row.addWidget(self.approve_button) button_row.addWidget(self.reject_button) layout.addWidget(self.result_text, 1) layout.addLayout(button_row) return panel @staticmethod def _panel(name: str) -> QFrame: panel = QFrame() panel.setObjectName(name) panel.setFrameShape(QFrame.Shape.NoFrame) return panel @staticmethod def _section_title(text: str) -> QLabel: label = QLabel(text) label.setObjectName("sectionTitle") return label def _apply_style(self) -> None: self.setStyleSheet( """ #appRoot { background: #eef1f4; } QWidget { color: #18202a; font-size: 13px; } QLabel { background: transparent; } #sidebar { background: #151d27; border-right: 1px solid #25313e; } #workspace { background: #eef1f4; } #appTitle { color: #f8fafc; font-size: 24px; font-weight: 800; } #sidebarSection { color: #aeb8c4; font-size: 12px; font-weight: 800; letter-spacing: 0px; } #pageTitle { color: #18202a; font-size: 24px; font-weight: 800; } #headerContext { color: #637080; font-size: 13px; } #topMeta { background: #ffffff; color: #56616f; border: 1px solid #d7dde5; border-radius: 10px; padding: 8px 12px; font-weight: 700; } #statusBadge { background: #263241; color: #dce3ea; border: 1px solid #334253; border-radius: 10px; padding: 9px 12px; font-weight: 800; } #statusBadge[state="waiting_decision"], #statusBadge[state="waiting"] { background: #382e16; border-color: #9c7a25; color: #f7d677; } #statusBadge[state="awaiting_approval"], #statusBadge[state="running"] { background: #173044; border-color: #34708f; color: #a6d8ef; } #statusBadge[state="approved"] { background: #173526; border-color: #2f7b52; color: #99ddb6; } #statusBadge[state="blocked"], #statusBadge[state="rejected"], #statusBadge[state="failed"] { background: #3e1e1e; border-color: #9d4a40; color: #f2b0a7; } #intentInput { background: #0f1721; color: #f4f7fb; border: 1px solid #2a3847; border-radius: 12px; padding: 10px; selection-background-color: #2e6b77; } #intentInput::placeholder { color: #758293; } #resultText { background: #f8fafc; color: #202a35; border: 1px solid #d8dee6; border-radius: 12px; padding: 10px; selection-background-color: #cde3ec; } #primaryButton { background: #2f7783; color: #ffffff; border: 1px solid #286773; border-radius: 12px; padding: 10px 14px; min-height: 34px; font-weight: 800; } #primaryButton:hover:!disabled { background: #286b76; } #secondaryButton { background: #ffffff; color: #2e3946; border: 1px solid #cbd3dc; border-radius: 12px; padding: 10px 14px; min-height: 34px; font-weight: 800; } #secondaryButton:hover:!disabled { background: #f4f7fa; } QPushButton:disabled { background: #e4e8ed; border: 1px solid #d3d9e1; color: #9aa5b2; } #jobsScroll, #phaseScroll { background: transparent; border: 0; } #jobsContent, #phaseContent, #actionColumn { background: transparent; } #jobButton { background: #1c2734; color: #c7d0dc; border: 1px solid #2c3948; border-radius: 12px; padding: 11px 12px; text-align: left; font-weight: 700; } #jobButton:hover { background: #243244; } #jobButton[selected="true"] { background: #eaf7fa; color: #14313a; border-color: #72aeb8; } #planPanel, #decisionPanel, #resultPanel { background: #ffffff; border: 1px solid #d9dfe7; border-radius: 18px; } #sectionTitle { color: #18202a; font-size: 16px; font-weight: 800; } #phaseRow { background: #f8fafc; border: 1px solid #e0e5ec; border-radius: 14px; } #phaseRow[active="true"] { background: #fff8e8; border-color: #e5be5f; } #phaseName { color: #18202a; font-weight: 800; font-size: 14px; } #phaseSummary { color: #5b6775; font-size: 13px; } #statusPill { border-radius: 10px; padding: 5px 9px; font-weight: 800; font-size: 12px; } #statusPill[state="pending"] { background: #edf1f5; color: #657181; } #statusPill[state="waiting"], #statusPill[state="waiting_decision"] { background: #fff0c6; color: #735300; } #statusPill[state="completed"], #statusPill[state="approved"] { background: #daf3e3; color: #1f6339; } #statusPill[state="running"], #statusPill[state="awaiting_approval"] { background: #dceff7; color: #22566d; } #statusPill[state="blocked"], #statusPill[state="failed"], #statusPill[state="rejected"] { background: #f9ddd8; color: #84372c; } #decisionTitle { color: #18202a; font-size: 16px; font-weight: 800; } #bodyText { color: #526070; line-height: 1.35; } #optionButton { background: #f7f9fb; color: #26313d; border: 1px solid #d9e0e8; border-radius: 12px; padding: 10px 12px; text-align: left; font-weight: 700; } #optionButton:hover { background: #eef5f7; } #optionButton[selected="true"] { background: #e5f4f7; color: #14353c; border-color: #5ca2ad; } #decisionNote { background: #f8fafc; color: #202a35; border: 1px solid #d8dee6; border-radius: 12px; padding: 9px; } QScrollBar:vertical { background: transparent; width: 10px; margin: 0; } QScrollBar::handle:vertical { background: #c6ced8; border-radius: 5px; min-height: 36px; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } """ ) def _submit_intent(self) -> None: try: job_id = self.kernel.submit_intent(self.intent_input.toPlainText()) except ValueError as exc: QMessageBox.warning(self, "Auftrag fehlt", str(exc)) return self.active_job_id = job_id self.intent_input.clear() self.refresh() def _select_job(self, job_id: int) -> None: self.active_job_id = job_id self.refresh() def _select_decision_option(self, decision_id: int, option: str) -> None: self._selected_decision_id = decision_id self._selected_decision_option = option for button in self.decision_content.findChildren(QPushButton): if button.objectName() != "optionButton": continue selected = button.property("decision_id") == decision_id and button.property("option") == option button.setProperty("selected", "true" if selected else "false") self._refresh_widget_style(button) def _answer_decision(self) -> None: if self._selected_decision_id is None or self._selected_decision_option is None: QMessageBox.warning(self, "Entscheidung fehlt", "Bitte eine Option auswählen.") return self.kernel.answer_decision( self._selected_decision_id, self._selected_decision_option, self.decision_note.toPlainText(), ) self._selected_decision_id = None self._selected_decision_option = None self.refresh() def _approve_result(self, approved: bool) -> None: if self.active_job_id is None: return try: self.kernel.approve_result(self.active_job_id, approved) except KeyError: QMessageBox.information(self, "Kein Ergebnis", "Für diesen Auftrag liegt noch kein Ergebnis vor.") return self.refresh() def refresh(self) -> None: self._refresh_jobs() snapshot = self._active_snapshot() self._refresh_header(snapshot) self._refresh_plan(snapshot) self._refresh_decisions(snapshot) self._refresh_results(snapshot) def _refresh_jobs(self) -> None: jobs = self.kernel.list_jobs() if self.active_job_id is None and jobs: self.active_job_id = jobs[0].id self._clear_layout(self.jobs_layout) for job in jobs: self.jobs_layout.addWidget(self._job_button(job)) self.jobs_layout.addStretch(1) def _job_button(self, job: Job) -> QPushButton: status = STATUS_LABELS.get(job.status, job.status) intent = job.intent if len(job.intent) <= 48 else f"{job.intent[:45]}..." button = QPushButton(f"#{job.id} · {status}\n{intent}") button.setObjectName("jobButton") button.setProperty("selected", "true" if job.id == self.active_job_id else "false") button.setCursor(Qt.CursorShape.PointingHandCursor) button.clicked.connect(partial(self._select_job, job.id)) button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) return button def _active_snapshot(self) -> JobSnapshot | None: if self.active_job_id is None: return None try: return self.kernel.get_job_status(self.active_job_id) except KeyError: self.active_job_id = None return None def _refresh_header(self, snapshot: JobSnapshot | None) -> None: if snapshot is None: self.header_context.setText("Kein Auftrag aktiv.") self.status_badge.setText("Bereit") self._set_status_state("idle") return status = STATUS_LABELS.get(snapshot.job.status, snapshot.job.status) self.status_badge.setText(status) self._set_status_state(snapshot.job.status) self.header_context.setText(f"#{snapshot.job.id} · {snapshot.job.intent}") def _refresh_plan(self, snapshot: JobSnapshot | None) -> None: self._clear_layout(self.phase_layout) if snapshot is None: self.plan_status.setText("Human-Gates: keine offen") self.phase_layout.addWidget(self._empty_label("Kein Auftrag ausgewählt.")) self.phase_layout.addStretch(1) return open_decisions = [decision for decision in snapshot.decisions if decision.status == "open"] gate_text = f"{len(open_decisions)} offen" if open_decisions else "keine offen" self.plan_status.setText(f"Human-Gates: {gate_text}") for phase in snapshot.phases: self.phase_layout.addWidget(self._phase_row(phase)) self.phase_layout.addStretch(1) def _phase_row(self, phase: Phase) -> QWidget: row = QFrame() row.setObjectName("phaseRow") row.setProperty("active", "true" if phase.status in {"running", "waiting"} else "false") layout = QHBoxLayout(row) layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(14) text_group = QVBoxLayout() text_group.setSpacing(3) name = QLabel(PHASE_LABELS.get(phase.name, phase.name)) name.setObjectName("phaseName") summary = QLabel(phase.summary or "Noch keine Zusammenfassung.") summary.setObjectName("phaseSummary") summary.setWordWrap(True) text_group.addWidget(name) text_group.addWidget(summary) status = QLabel(STATUS_LABELS.get(phase.status, phase.status)) status.setObjectName("statusPill") status.setProperty("state", phase.status) status.setAlignment(Qt.AlignmentFlag.AlignCenter) status.setMinimumWidth(112) layout.addLayout(text_group, 1) layout.addWidget(status, 0, Qt.AlignmentFlag.AlignTop) return row def _refresh_decisions(self, snapshot: JobSnapshot | None) -> None: self._clear_layout(self.decision_content_layout) self._selected_decision_id = None self._selected_decision_option = None if snapshot is None: self.decision_content_layout.addWidget(self._empty_label("Kein Auftrag ausgewählt.")) self.decision_content_layout.addStretch(1) return open_decisions = [decision for decision in snapshot.decisions if decision.status == "open"] if not open_decisions: self.decision_content_layout.addWidget(self._empty_label("Keine offenen Human-Gates.")) self.decision_content_layout.addStretch(1) return decision = open_decisions[0] title = QLabel(decision.title) title.setObjectName("decisionTitle") title.setWordWrap(True) context = QLabel(decision.context) context.setWordWrap(True) context.setObjectName("bodyText") self.decision_content_layout.addWidget(title) self.decision_content_layout.addWidget(context) for index, option in enumerate(decision.options): button = QPushButton(option) button.setObjectName("optionButton") button.setProperty("decision_id", decision.id) button.setProperty("option", option) button.setProperty("selected", "true" if index == 0 else "false") button.setCheckable(False) button.setCursor(Qt.CursorShape.PointingHandCursor) button.clicked.connect(partial(self._select_decision_option, decision.id, option)) self.decision_content_layout.addWidget(button) if index == 0: self._selected_decision_id = decision.id self._selected_decision_option = option self.decision_note = QTextEdit() self.decision_note.setObjectName("decisionNote") self.decision_note.setAcceptRichText(False) self.decision_note.setPlaceholderText("Optionaler Hinweis") self.decision_note.setFixedHeight(86) submit = QPushButton("Antwort senden") submit.setObjectName("primaryButton") submit.clicked.connect(self._answer_decision) self.decision_content_layout.addWidget(self.decision_note) self.decision_content_layout.addWidget(submit) self.decision_content_layout.addStretch(1) def _refresh_results(self, snapshot: JobSnapshot | None) -> None: if snapshot is None or snapshot.result is None: self.result_text.clear() self.approve_button.setEnabled(False) self.reject_button.setEnabled(False) return result = snapshot.result decisions = "\n".join(f"- {item}" for item in result.decisions) or "- Keine" next_steps = "\n".join(f"- {item}" for item in result.next_steps) or "- Keine" blocked = "Ja" if result.blocked else "Nein" approval = ( "Offen" if result.approved is None else "Freigegeben" if result.approved else "Abgelehnt" ) self.result_text.setPlainText( "\n\n".join( [ f"Was gemacht wurde\n{result.summary}", f"Was blockiert ist\n{blocked}", f"Was du entschieden hast\n{decisions}", f"Was als Nächstes sinnvoll ist\n{next_steps}", f"Freigabe\n{approval}", ] ) ) buttons_enabled = result.approved is None and not result.blocked self.approve_button.setEnabled(buttons_enabled) self.reject_button.setEnabled(buttons_enabled) @staticmethod def _empty_label(text: str) -> QLabel: label = QLabel(text) label.setObjectName("bodyText") label.setWordWrap(True) return label def _set_status_state(self, state: str) -> None: self.status_badge.setProperty("state", state) self._refresh_widget_style(self.status_badge) @staticmethod def _refresh_widget_style(widget: QWidget) -> None: widget.style().unpolish(widget) widget.style().polish(widget) @staticmethod def _clear_layout(layout: QLayout) -> None: while layout.count(): item = layout.takeAt(0) widget = item.widget() child_layout = item.layout() if widget is not None: widget.setParent(None) widget.deleteLater() elif child_layout is not None: MainWindow._clear_layout(child_layout) def run_app(argv: list[str]) -> int: QApplication.setOrganizationName("CLFGUI") QApplication.setApplicationName("CLFGUI") QApplication.setStyle("Fusion") app = QApplication(argv) window = MainWindow() window.show() return app.exec()