From e4174218ed3847ad1a70b47478a4ec515883b6d6 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 22 Oct 2025 13:48:47 -0300 Subject: [PATCH] 1st commit --- ramble.py | 752 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 752 insertions(+) create mode 100644 ramble.py diff --git a/ramble.py b/ramble.py new file mode 100644 index 0000000..be714a3 --- /dev/null +++ b/ramble.py @@ -0,0 +1,752 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r""" +ramble.py — Lean "Ramble → Generate" dialog (PlantUML + optional images) + +Changes (per latest request) +- Fields are always editable (no Edit button) +- Lock via checkbox; locked fields are highlighted and not overwritten by Generate +- Hide UML code; only show rendered PlantUML image +- Scrollable main content so long generations fit smaller screens +- "Generate / Update" moved under the Ramble input +- Bottom-right "Submit" button returns JSON (summary + fields) via the public API and prints in CLI demo +""" + +from __future__ import annotations +from dataclasses import dataclass +from typing import Dict, List, Optional, Any, Tuple, Protocol, runtime_checkable, Mapping, cast +import os, sys, json, textwrap, base64, re, time, shutil, subprocess, argparse, threading + +try: + import requests # type: ignore +except ImportError: + requests = None # runtime-guarded + +# --- Qt Import (PySide6 preferred; fallback to PyQt5) ------------------------- +QT_LIB = None +try: + from PySide6.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject, + QThreadPool, QRunnable, Signal, Slot) + from PySide6.QtGui import (QFont, QLinearGradient, QPainter, QPalette, QColor, QPixmap, QTextCursor) + from PySide6.QtWidgets import ( + QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy, + QFrame, QMessageBox, QScrollArea, QCheckBox, QSpacerItem + ) + QT_LIB = "PySide6" +except ImportError: + from PyQt5.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject, + QThreadPool, QRunnable, pyqtSignal as Signal, pyqtSlot as Slot) + from PyQt5.QtGui import (QFont, QLinearGradient, QPainter, QPalette, QColor, QPixmap, QTextCursor) + from PyQt5.QtWidgets import ( + QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy, + QFrame, QMessageBox, QScrollArea, QCheckBox, QSpacerItem + ) + QT_LIB = "PyQt5" + + +# --- Provider Protocol --------------------------------------------------------- +@runtime_checkable +class RambleProvider(Protocol): + """Return a dict with: summary, fields, uml_blocks, image_descriptions.""" + def generate(self, *, prompt: str, ramble_text: str, fields: List[str]) -> Dict[str, Any]: ... + + +# --- Mock Provider (offline/testing) ------------------------------------------ +class MockProvider: + def generate(self, *, prompt: str, ramble_text: str, fields: List[str]) -> Dict[str, Any]: + words = ramble_text.strip().split() + cap = min(25, len(words)) + summary = " ".join(words[-cap:]) if words else "(no content yet)" + summary = (summary[:1].upper() + summary[1:]).rstrip(".") + "." + field_map = {f: f"{f}: Derived from ramble ({len(words)} words)." for f in fields} + uml_blocks = [ + ("@startuml\nactor User\nUser -> System: Ramble\nSystem -> LLM: Generate\nLLM --> System: Summary/Fields/UML\nSystem --> User: Updates\n@enduml", None) + ] + image_descriptions = [ + "A conceptual sketch representing the core entities mentioned in the ramble.", + "An abstract icon related to the domain of the idea." + ] + return { + "summary": summary, + "fields": field_map, + "uml_blocks": uml_blocks, + "image_descriptions": image_descriptions, + } + + +# --- Claude CLI Provider ------------------------------------------------------- +class ClaudeCLIProvider: + def __init__( + self, + cmd: str = "claude", + extra_args: Optional[List[str]] = None, + timeout_s: int = 120, + tail_chars: int = 8000, + use_arg_p: bool = True, + debug: bool = False, + log_path: str = "/tmp/ramble_claude.log", + ): + self.cmd = shutil.which(cmd) or cmd + self.extra_args = extra_args or [] + self.timeout_s = timeout_s + self.tail_chars = tail_chars + self.use_arg_p = use_arg_p + self.debug = debug + self.log_path = log_path + + def _log(self, msg: str): + if not self.debug: return + with open(self.log_path, "a", encoding="utf-8") as f: + print(f"[{time.strftime('%H:%M:%S')}] {msg}", file=f) + + def _build_prompt(self, *, user_prompt: str, ramble_text: str, fields: List[str]) -> str: + fields_list = "\n".join(f'- "{f}"' for f in fields) + ramble_tail = ramble_text[-self.tail_chars:] if self.tail_chars and len(ramble_text) > self.tail_chars else ramble_text + return textwrap.dedent(f"""\ + You are an assistant that returns ONLY compact JSON. No preamble, no markdown, no code fences. + + Task: + 1) Read the user's 'ramble' text. + 2) Produce a JSON object with exactly these keys: + "summary": string (≤ 2 sentences) + "fields": object with these keys: {fields_list} + "uml_blocks": array of objects: {{"uml_text": string, "png_base64": null}} + "image_descriptions": array of short strings + Constraints: + - Output MUST be valid JSON. No markdown fences. No extra keys. + + Context: + - Prompt: {user_prompt} + - Ramble (tail, possibly truncated): + {ramble_tail} + """).strip() + "\n" + + @staticmethod + def _strip_fences(s: str) -> str: + s = s.strip() + if s.startswith("```"): + s = re.sub(r"^```(?:json)?\s*", "", s, flags=re.IGNORECASE) + s = re.sub(r"\s*```$", "", s) + return s.strip() + + def _run_once(self, prompt_text: str, timeout: int) -> str: + if self.use_arg_p: + argv = [self.cmd, "-p", prompt_text, *self.extra_args] + stdin = None + else: + argv = [self.cmd, *self.extra_args] + stdin = prompt_text + + self._log(f"argv: {argv}") + t0 = time.time() + try: + proc = subprocess.run( + argv, input=stdin, capture_output=True, text=True, + timeout=timeout, check=False, + ) + except FileNotFoundError as e: + self._log(f"FileNotFoundError: {e}") + raise RuntimeError(f"Claude CLI not found at {self.cmd}.") from e + except subprocess.TimeoutExpired: + self._log("TimeoutExpired") + raise + + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + self._log(f"rc={proc.returncode} elapsed={time.time()-t0:.2f}s out={len(out)}B err={len(err)}B") + if proc.returncode != 0: + raise RuntimeError(f"Claude CLI exited {proc.returncode}:\n{err or out or '(no output)'}") + return out + + def _normalize(self, raw: Dict[str, Any], fields: List[str]) -> Dict[str, Any]: + fields_map: Mapping[str, Any] = raw.get("fields", {}) or {} + uml_objs = raw.get("uml_blocks", []) or [] + image_desc = raw.get("image_descriptions", []) or [] + uml_blocks: List[tuple[str, Optional[bytes]]] = [] + for obj in uml_objs: + uml_text = (obj or {}).get("uml_text") or "" + png_b64 = (obj or {}).get("png_base64") + png_bytes = None + if isinstance(png_b64, str) and png_b64: + try: + png_bytes = base64.b64decode(png_b64) + except Exception: + png_bytes = None + uml_blocks.append((uml_text, png_bytes)) + normalized_fields = {name: str(fields_map.get(name, "")) for name in fields} + return { + "summary": str(raw.get("summary", "")), + "fields": normalized_fields, + "uml_blocks": uml_blocks, + "image_descriptions": [str(s) for s in image_desc], + } + + def generate(self, *, prompt: str, ramble_text: str, fields: List[str]) -> Dict[str, Any]: + p = self._build_prompt(user_prompt=prompt, ramble_text=ramble_text, fields=fields) + try: + out = self._run_once(p, timeout=self.timeout_s) + except subprocess.TimeoutExpired: + shorter = self._build_prompt(user_prompt=prompt, ramble_text=ramble_text[-3000:], fields=fields) + out = self._run_once(shorter, timeout=max(45, self.timeout_s // 2)) + + txt = self._strip_fences(out) + try: + data = json.loads(txt) + except json.JSONDecodeError: + words = txt.split() + summary = " ".join(words[:30]) + ("..." if len(words) > 30 else "") + data = { + "summary": summary or "(no content)", + "fields": {f: f"{f}: {summary}" for f in fields}, + "uml_blocks": [{"uml_text": "@startuml\nactor User\nUser -> System: Ramble\n@enduml", "png_base64": None}], + "image_descriptions": ["Abstract illustration related to the idea."], + } + return self._normalize(data, fields) + + +# --- Helpers: Fade label + fading ramble edit --------------------------------- +class FadeLabel(QLabel): + def __init__(self, parent=None): + super().__init__(parent) + self._anim = QPropertyAnimation(self, b"windowOpacity") + self._anim.setDuration(300) + self._anim.setEasingCurve(QEasingCurve.InOutQuad) + self.setWindowOpacity(1.0) + + def fade_to_text(self, text: str): + def set_text(): + self.setText(text) + try: + self._anim.finished.disconnect(set_text) # type: ignore + except Exception: + pass + self._anim.setStartValue(0.0) + self._anim.setEndValue(1.0) + self._anim.start() + self._anim.stop() + self._anim.setStartValue(1.0) + self._anim.setEndValue(0.0) + self._anim.finished.connect(set_text) # type: ignore + self._anim.start() + + +class FadingRambleEdit(QPlainTextEdit): + def __init__(self, max_blocks: int = 500, parent=None): + super().__init__(parent) + self.setPlaceholderText("Type freely… just keep rambling.") + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setFrameStyle(QFrame.StyledPanel | QFrame.Raised) + self.document().setMaximumBlockCount(max_blocks) + font = QFont("DejaVu Sans Mono"); font.setPointSize(10) + self.setFont(font) + self.setFixedHeight(180) + + def wheelEvent(self, event): # prevent manual scrolling + event.ignore() + + def keyPressEvent(self, event): + super().keyPressEvent(event) + self.moveCursor(QTextCursor.End) + + def paintEvent(self, e): + super().paintEvent(e) + painter = QPainter(self.viewport()) + grad = QLinearGradient(0, 0, 0, 30) + bg = self.palette().color(QPalette.ColorRole.Base) + grad.setColorAt(0.0, QColor(bg.red(), bg.green(), bg.blue(), 255)) + grad.setColorAt(1.0, QColor(bg.red(), bg.green(), bg.blue(), 0)) + painter.fillRect(0, 0, self.viewport().width(), 30, grad) + painter.end() + + +# --- Image backends ------------------------------------------------------------ +def have_requests_or_raise(): + if requests is None: + raise RuntimeError("The 'requests' library is required for image fetching. Install with: pip install requests") + +def fetch_image_stability(prompt: str) -> Optional[bytes]: + have_requests_or_raise(); assert requests is not None + api_key = os.getenv("STABILITY_API_KEY", "").strip() + if not api_key: + raise RuntimeError("STABILITY_API_KEY is not set") + url = "https://api.stability.ai/v2beta/stable-image/generate/core" + headers = {"Authorization": f"Bearer {api_key}", "Accept": "image/png"} + data = {"prompt": prompt[:1000], "output_format": "png"} + resp = requests.post(url, headers=headers, data=data, timeout=60) + if resp.status_code == 200 and resp.content: + return resp.content + raise RuntimeError(f"Stability API error: {resp.status_code} {resp.text[:200]}") + +def fetch_image_pexels(query: str) -> Optional[bytes]: + have_requests_or_raise(); assert requests is not None + api_key = os.getenv("PEXELS_API_KEY", "").strip() + if not api_key: + raise RuntimeError("PEXELS_API_KEY is not set") + s_url = "https://api.pexels.com/v1/search" + headers = {"Authorization": api_key} + params = {"query": query, "per_page": 1} + r = requests.get(s_url, headers=headers, params=params, timeout=30) + r.raise_for_status() + data = r.json() + photos = data.get("photos") or [] + if not photos: + return None + src = photos[0].get("src") or {} + raw_url = src.get("original") or src.get("large") or src.get("medium") + if not raw_url: + return None + img = requests.get(raw_url, timeout=60) + img.raise_for_status() + return img.content + + +# --- PlantUML rendering -------------------------------------------------------- +def ensure_plantuml_present(): + exe = shutil.which("plantuml") + if not exe: + raise RuntimeError("plantuml not found in PATH. Install it (e.g., sudo apt install plantuml).") + return exe + +def render_plantuml_to_png_bytes(uml_text: str) -> bytes: + exe = ensure_plantuml_present() + proc = subprocess.run( + [exe, "-tpng", "-pipe"], + input=uml_text.encode("utf-8"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if proc.returncode != 0 or not proc.stdout: + raise RuntimeError(f"plantuml failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}") + return proc.stdout + + +# --- Background worker --------------------------------------------------------- +class GenWorker(QRunnable): + class Signals(QObject): + finished = Signal(dict) + error = Signal(str) + def __init__(self, provider: RambleProvider, payload: Dict[str, Any]): + super().__init__() + self.provider = provider + self.payload = payload + self.signals = GenWorker.Signals() + def run(self): + try: + data = self.provider.generate(**self.payload) + self.signals.finished.emit(data) # type: ignore + except Exception as e: + self.signals.error.emit(str(e)) # type: ignore + + +# --- Main Dialog --------------------------------------------------------------- +@dataclass +class RambleResult: + summary: str + fields: Dict[str, str] + uml_blocks: List[Tuple[str, Optional[bytes]]] + image_descriptions: List[str] + +class RambleDialog(QDialog): + def __init__( + self, + *, + prompt: str, + fields: List[str], + hints: Optional[List[str]] = None, + provider: Optional[RambleProvider] = None, + enable_stability: bool = False, + enable_pexels: bool = False, + parent: Optional[QWidget] = None + ): + super().__init__(parent) + self.setWindowTitle("Ramble → Generate") + self.resize(1100, 750) # sensible default, scroll keeps it usable + + self.provider = provider or cast(RambleProvider, MockProvider()) + self._prompt_text = prompt + self._fields = fields[:] + self._hints = hints or [ + "What is it called?", + "What is it for?", + "What type of idea is it?", + "What problem does it solve?", + "Who benefits most?", + "What are success criteria?", + ] + self.enable_stability = enable_stability + self.enable_pexels = enable_pexels and not enable_stability # stability takes precedence + + self.thread_pool = QThreadPool.globalInstance() + self.result: Optional[RambleResult] = None + + # Track per-field lock states + self.field_locked: Dict[str, bool] = {f: False for f in self._fields} + + # --- Outer layout: scrollable content + fixed footer row + outer = QVBoxLayout(self) + + # Scroll area wraps the main content + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + content = QWidget() + self.scroll.setWidget(content) + outer.addWidget(self.scroll, 1) + + # Footer with Submit button (fixed) + footer = QHBoxLayout() + footer.addStretch(1) + self.submit_btn = QPushButton("Submit") + self.submit_btn.clicked.connect(self.on_submit) # type: ignore + footer.addWidget(self.submit_btn) + outer.addLayout(footer) + + # === Inside the scrollable content === + grid = QGridLayout(content) + grid.setHorizontalSpacing(16) + grid.setVerticalSpacing(12) + + # Top prompt + hint + prompt_label = QLabel(f"{self._prompt_text}"); prompt_label.setWordWrap(True) + hint_label = FadeLabel(); hint_label.setText(self._hints[0]) + hint_label.setStyleSheet("color:#666; font-style:italic;") + grid.addWidget(prompt_label, 0, 0, 1, 2) + grid.addWidget(hint_label, 1, 0, 1, 2) + self.hint_label = hint_label + + # Left column ----------------------------------------------------------- + left_col = QVBoxLayout() + + # Ramble input + self.ramble_edit = FadingRambleEdit(max_blocks=600) + left_col.addWidget(self.ramble_edit) + + # Generate button right under input + gen_row = QHBoxLayout() + self.generate_btn = QPushButton("Generate / Update") + self.generate_btn.clicked.connect(self.on_generate) # type: ignore + self.provider_label = QLabel(f"Provider: {type(self.provider).__name__}") + self.provider_label.setStyleSheet("color:#555;") + gen_row.addWidget(self.generate_btn) + gen_row.addStretch(1) + gen_row.addWidget(self.provider_label) + left_col.addLayout(gen_row) + + # UML group (image only) + uml_group = QGroupBox("Generated UML (PlantUML)") + uml_v = QVBoxLayout(uml_group) + self.uml_image_label = QLabel() + self.uml_image_label.setAlignment(Qt.AlignCenter) + if QT_LIB == "PyQt5": + self.uml_image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + else: + self.uml_image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + uml_v.addWidget(self.uml_image_label) + left_col.addWidget(uml_group) + + # Images / descriptions + img_group = QGroupBox("Generated Images / Descriptions") + img_v = QVBoxLayout(img_group) + self.img_desc_text = QTextEdit(); self.img_desc_text.setReadOnly(True); self.img_desc_text.setMinimumHeight(90) + self.img_desc_text.setPlaceholderText("Descriptions of suggested/related images…") + img_v.addWidget(self.img_desc_text) + + self.img_scroll_inner = QScrollArea() + self.img_scroll_inner.setWidgetResizable(True) + self.img_panel = QWidget() + self.img_panel_layout = QVBoxLayout(self.img_panel) + self.img_scroll_inner.setWidget(self.img_panel) + img_v.addWidget(self.img_scroll_inner) + left_col.addWidget(img_group) + + # Right column ---------------------------------------------------------- + right_col = QVBoxLayout() + + # Summary (editable) + summary_group = QGroupBox("Generated Summary") + sg_v = QVBoxLayout(summary_group) + self.summary_edit = QTextEdit() + self.summary_edit.setReadOnly(False) + self.summary_edit.setMinimumHeight(110) + self.summary_edit.setPlaceholderText("Summary will appear here…") + sg_v.addWidget(self.summary_edit) + right_col.addWidget(summary_group) + + # Fields (editable) with Lock checkbox + fields_group = QGroupBox("Generated Fields") + fg_form = QFormLayout(fields_group) + self.field_outputs: Dict[str, QTextEdit] = {} + self.field_lock_boxes: Dict[str, QCheckBox] = {} + + def apply_lock_style(widget: QTextEdit, locked: bool): + if locked: + widget.setStyleSheet("background-color: #fff7cc;") # light yellow + else: + widget.setStyleSheet("") + + for f in self._fields: + row = QHBoxLayout() + te = QTextEdit() + te.setReadOnly(False) # always editable + te.setMinimumHeight(70) + te.setPlaceholderText(f"{f}…") + self.field_outputs[f] = te + + lock = QCheckBox("Lock") + lock.setChecked(False) + self.field_lock_boxes[f] = lock + lock.stateChanged.connect(lambda _=0, name=f: apply_lock_style(self.field_outputs[name], self.field_lock_boxes[name].isChecked())) # type: ignore + + row.addWidget(te, 1) + row.addWidget(lock) + fg_form.addRow(f"{f}:", row) + apply_lock_style(te, False) + + right_col.addWidget(fields_group, 1) + + # Status label at the very bottom of content (inside scroll) + self.status_label = QLabel(""); self.status_label.setStyleSheet("color:#777;") + right_col.addWidget(self.status_label) + + # Place columns into the grid + left_w = QWidget(); left_w.setLayout(left_col) + right_w = QWidget(); right_w.setLayout(right_col) + grid.addWidget(left_w, 2, 0) + grid.addWidget(right_w, 2, 1) + + self._setup_hint_timer() + + def _setup_hint_timer(self): + self._hint_idx = 0 + self._hint_timer = QTimer(self) + self._hint_timer.setInterval(3500) + self._hint_timer.timeout.connect(self._advance_hint) # type: ignore + self._hint_timer.start() + + def _advance_hint(self): + if not self._hints: return + self._hint_idx = (self._hint_idx + 1) % len(self._hints) + self.hint_label.fade_to_text(self._hints[self._hint_idx]) + + # Actions ------------------------------------------------------------------- + @Slot() + def on_generate(self): + ramble_text = self.ramble_edit.toPlainText() + if not ramble_text.strip(): + QMessageBox.information(self, "Nothing to generate", "Type your thoughts first, then click Generate / Update.") + return + + self._set_busy(True, "Generating…") + payload = {"prompt": self._prompt_text, "ramble_text": ramble_text, "fields": self._fields} + worker = GenWorker(self.provider, payload) + worker.signals.finished.connect(self._on_generated) # type: ignore + worker.signals.error.connect(self._on_gen_error) # type: ignore + self.thread_pool.start(worker) + + def _on_generated(self, data: Dict[str, Any]): + # Summary: do not auto-overwrite if locked? (No lock UI for summary by request; always overwrite but is editable) + self.summary_edit.setPlainText(data.get("summary", "")) + + # Fields: respect locks + new_fields: Dict[str, str] = data.get("fields", {}) + for name, widget in self.field_outputs.items(): + if self.field_lock_boxes.get(name, QCheckBox()).isChecked(): + continue # keep user's text + widget.setPlainText(new_fields.get(name, "")) + + # UML: render PlantUML if provider didn't supply PNG bytes (show image only) + uml_blocks = data.get("uml_blocks", []) + self.uml_image_label.clear() + for (uml_text, maybe_png) in uml_blocks: + png_bytes = maybe_png + if not png_bytes and uml_text: + try: + png_bytes = render_plantuml_to_png_bytes(uml_text) + except Exception as e: + print(f"[UML render] {e}", file=sys.stderr) + png_bytes = None + if png_bytes: + pix = QPixmap() + if pix.loadFromData(png_bytes): + self.uml_image_label.setPixmap(pix) + break # show first image only + + # Images / descriptions + img_desc = [str(s) for s in data.get("image_descriptions", [])] + self.img_desc_text.setPlainText("\n\n".join(f"• {d}" for d in img_desc)) + self._clear_img_panel() + + if img_desc and (self.enable_stability or self.enable_pexels): + threading.Thread(target=self._fetch_and_show_images, args=(img_desc,), daemon=True).start() + + # Store result (fields reflect current UI, including locks/edits) + self.result = RambleResult( + summary=self.summary_edit.toPlainText(), + fields={k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()}, + uml_blocks=uml_blocks, + image_descriptions=img_desc, + ) + self._set_busy(False, "Ready.") + + def _fetch_and_show_images(self, descriptions: List[str]): + images: List[Tuple[str, Optional[bytes], Optional[str]]] = [] + for d in descriptions: + try: + if self.enable_stability: + img_bytes = fetch_image_stability(d) + elif self.enable_pexels: + img_bytes = fetch_image_pexels(d) + else: + img_bytes = None + images.append((d, img_bytes, None)) + except Exception as e: + images.append((d, None, str(e))) + + def apply(): + for (label, img_bytes, err) in images: + row = QHBoxLayout() + lbl = QLabel(label); lbl.setWordWrap(True) + row.addWidget(lbl, 1) + if img_bytes: + pix = QPixmap() + if pix.loadFromData(img_bytes): + thumb = QLabel() + thumb.setPixmap(pix) + thumb.setAlignment(Qt.AlignCenter) + if QT_LIB == "PyQt5": + thumb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + else: + thumb.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + row.addWidget(thumb, 1) + elif err: + err_lbl = QLabel(f"(image error: {err})"); err_lbl.setStyleSheet("color:#b33;") + row.addWidget(err_lbl) + container = QWidget(); container.setLayout(row) + self.img_panel_layout.addWidget(container) + self.img_panel_layout.addStretch(1) + QTimer.singleShot(0, apply) + + def _clear_img_panel(self): + lay = self.img_panel_layout + while lay.count(): + item = lay.takeAt(0) + w = item.widget() + if w: + w.setParent(None) + + def _on_gen_error(self, msg: str): + self._set_busy(False, "Error.") + QMessageBox.critical(self, "Generation Error", msg) + + def _set_busy(self, busy: bool, msg: str): + self.generate_btn.setEnabled(not busy) + self.submit_btn.setEnabled(not busy) + self.status_label.setText(msg) + + @Slot() + def on_submit(self): + """Collect current summary + fields and return them to caller.""" + self.result = RambleResult( + summary=self.summary_edit.toPlainText(), + fields={k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()}, + uml_blocks=[], # you can include PNG bytes if you want to pipe them out too + image_descriptions=self.img_desc_text.toPlainText().splitlines(), + ) + self.accept() + + +# --- Public API --------------------------------------------------------------- +def open_ramble_dialog( + *, + prompt: str, + fields: List[str], + hints: Optional[List[str]] = None, + provider: Optional[RambleProvider] = None, + enable_stability: bool = False, + enable_pexels: bool = False, + parent: Optional[QWidget] = None +) -> Optional[Dict[str, Any]]: + app_created = False + app = QApplication.instance() + if app is None: + app_created = True + app = QApplication(sys.argv) + + dlg = RambleDialog( + prompt=prompt, + fields=fields, + hints=hints, + provider=provider, + enable_stability=enable_stability, + enable_pexels=enable_pexels, + parent=parent + ) + rc = dlg.exec_() if QT_LIB == "PyQt5" else dlg.exec() + + out = None + if rc == QDialog.Accepted and dlg.result: + out = { + "summary": dlg.result.summary, + "fields": dlg.result.fields, + # keeping UML/image lists out of the returned JSON by default; add if you need them: + # "uml_blocks": dlg.result.uml_blocks, + # "image_descriptions": dlg.result.image_descriptions, + } + + if app_created: + app.quit() + return out + + +# --- CLI demo ------------------------------------------------------------------ +def parse_args(): + p = argparse.ArgumentParser(description="Ramble → Generate (PlantUML + optional images)") + p.add_argument("--provider", choices=["mock", "claude"], default="mock") + p.add_argument("--claude-cmd", default="claude", help="Path to claude CLI") + p.add_argument("--stability", action="store_true", help="Enable Stability AI images (needs STABILITY_API_KEY)") + p.add_argument("--pexels", action="store_true", help="Enable Pexels images (needs PEXELS_API_KEY); ignored if --stability set") + p.add_argument("--prompt", default="Explain your new feature idea") + p.add_argument("--fields", nargs="+", default=["Title", "Intent", "Problem it solves", "Brief overview of logical flow"]) + p.add_argument("--timeout", type=int, default=90) + p.add_argument("--tail", type=int, default=6000) + p.add_argument("--debug", action="store_true") + return p.parse_args() + +if __name__ == "__main__": + args = parse_args() + + if args.provider == "claude": + provider = cast(RambleProvider, ClaudeCLIProvider( + cmd=args.claude_cmd, + use_arg_p=True, + timeout_s=args.timeout, + tail_chars=args.tail, + debug=args.debug, + )) + else: + provider = cast(RambleProvider, MockProvider()) + + # Ensure PlantUML exists early (fail fast) + try: + ensure_plantuml_present() + except Exception as e: + print(f"[FATAL] {e}", file=sys.stderr); sys.exit(2) + + if (args.stability or args.pexels) and requests is None: + print("[FATAL] 'requests' is required for image backends. Install with: pip install requests", file=sys.stderr) + sys.exit(3) + + demo = open_ramble_dialog( + prompt=args.prompt, + fields=args.fields, + hints=None, + provider=provider, + enable_stability=args.stability, + enable_pexels=args.pexels, + ) + if demo: + # Emit JSON to stdout (standard practice for tool-style dialogs) + print(json.dumps(demo, ensure_ascii=False, indent=2))