diff --git a/ramble.py b/ramble.py index be714a3..feaddd6 100644 --- a/ramble.py +++ b/ramble.py @@ -1,15 +1,36 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- r""" -ramble.py — Lean "Ramble → Generate" dialog (PlantUML + optional images) +Ramble → Generate (PlantUML + optional images, locked-field context, per-field criteria) -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 +What’s new: +- Locked fields feed back into the prompt as authoritative context. +- Summary is just another field; all fields can have custom criteria. +- Fields are always editable; “Lock” checkbox prevents overwrite and highlights the field. +- “How to use” toggle with simple instructions. +- Soft colors; Ramble input visually stands out. +- Scrollable main content, Generate under ramble, Submit bottom-right returns JSON. + +CLI examples +------------ +# Mock provider (no external calls) +python3 ramble.py --fields Title Summary "Problem it solves" --criteria '{"Summary":"<=2 sentences","Title":"camelCase, no spaces, <=20 chars"}' + +# Claude CLI +python3 ramble.py \ + --provider=claude \ + --claude-cmd=/home/rob/.npm-global/bin/claude \ + --fields Title Summary "Problem it solves" "Brief overview" \ + --criteria '{"Summary":"<= 2 sentences","Title":"camelCase, no spaces, <= 20 characters"}' \ + --tail 6000 --debug + +Requirements +------------ +- PlantUML CLI in PATH -> sudo apt install plantuml +- PySide6 (or PyQt5) -> pip install PySide6 +- requests (only for images) -> pip install requests +- Stability key (optional) -> STABILITY_API_KEY +- Pexels key (optional) -> PEXELS_API_KEY """ from __future__ import annotations @@ -20,9 +41,9 @@ import os, sys, json, textwrap, base64, re, time, shutil, subprocess, argparse, try: import requests # type: ignore except ImportError: - requests = None # runtime-guarded + requests = None -# --- Qt Import (PySide6 preferred; fallback to PyQt5) ------------------------- +# ── Qt (PySide6 preferred; PyQt5 fallback) ──────────────────────────────────── QT_LIB = None try: from PySide6.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject, @@ -31,7 +52,7 @@ try: from PySide6.QtWidgets import ( QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy, - QFrame, QMessageBox, QScrollArea, QCheckBox, QSpacerItem + QFrame, QMessageBox, QScrollArea, QCheckBox ) QT_LIB = "PySide6" except ImportError: @@ -41,32 +62,54 @@ except ImportError: from PyQt5.QtWidgets import ( QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy, - QFrame, QMessageBox, QScrollArea, QCheckBox, QSpacerItem + QFrame, QMessageBox, QScrollArea, QCheckBox ) QT_LIB = "PyQt5" -# --- Provider Protocol --------------------------------------------------------- +# ── 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]: ... + """ + generate() must return: + - summary: str + - fields: Dict[str, str] + - uml_blocks: List[Tuple[str, Optional[bytes]]] + - image_descriptions: List[str] + """ + def generate( + self, + *, + prompt: str, + ramble_text: str, + fields: List[str], + field_criteria: Dict[str, str], + locked_context: Dict[str, str], + ) -> Dict[str, Any]: + ... -# --- Mock Provider (offline/testing) ------------------------------------------ +# ── Mock Provider ───────────────────────────────────────────────────────────── class MockProvider: - def generate(self, *, prompt: str, ramble_text: str, fields: List[str]) -> Dict[str, Any]: + def generate( + self, *, prompt: str, ramble_text: str, fields: List[str], + field_criteria: Dict[str, str], locked_context: Dict[str, 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} + field_map = {} + for f in fields: + crit = field_criteria.get(f, "").strip() + suffix = f" [criteria: {crit}]" if crit else "" + field_map[f] = f"{f}: Derived from ramble ({len(words)} words).{suffix}" 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." + "Illustrate the core actor interacting with the system.", + "Abstract icon that represents the idea’s domain." ] return { "summary": summary, @@ -76,7 +119,7 @@ class MockProvider: } -# --- Claude CLI Provider ------------------------------------------------------- +# ── Claude CLI Provider ─────────────────────────────────────────────────────── class ClaudeCLIProvider: def __init__( self, @@ -101,25 +144,41 @@ class ClaudeCLIProvider: 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) + def _build_prompt( + self, *, user_prompt: str, ramble_text: str, + fields: List[str], field_criteria: Dict[str,str], locked_context: Dict[str,str] + ) -> str: + fields_yaml = "\n".join([f'- "{f}"' for f in fields]) + criteria_yaml = "\n".join([f'- {name}: {field_criteria[name]}' for name in fields if field_criteria.get(name)]) + locked_yaml = "\n".join([f'- {k}: {locked_context[k]}' for k in locked_context.keys()]) if locked_context else "" 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. + Goal: + - Read the user's "ramble" and synthesize structured outputs. - Context: - - Prompt: {user_prompt} - - Ramble (tail, possibly truncated): + Required JSON keys: + - "summary": string + - "fields": object with these keys: {fields_yaml} + - "uml_blocks": array of objects: {{"uml_text": string, "png_base64": null}} + - "image_descriptions": array of short strings + + Per-field criteria (enforce tightly; if ambiguous, choose the most useful interpretation): + {criteria_yaml if criteria_yaml else "- (no special criteria provided)"} + + Guidance: + - The PlantUML diagram must reflect concrete entities/flows drawn from the ramble and any locked fields. + - Use 3–7 nodes where possible; prefer meaningful arrow labels. + - Image descriptions must be specific to this idea (avoid generic phrasing). + + Authoritative context from previously LOCKED fields (treat as ground truth if present): + {locked_yaml if locked_yaml else "- (none locked yet)"} + + Prompt: {user_prompt} + + Ramble (tail, possibly truncated): {ramble_tail} """).strip() + "\n" @@ -138,21 +197,15 @@ class ClaudeCLIProvider: 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, - ) + 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 - + 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") @@ -183,12 +236,24 @@ class ClaudeCLIProvider: "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) + def generate( + self, *, prompt: str, ramble_text: str, fields: List[str], + field_criteria: Dict[str, str], locked_context: Dict[str, str] + ) -> Dict[str, Any]: + p = self._build_prompt( + user_prompt=prompt, ramble_text=ramble_text, + fields=fields, field_criteria=field_criteria, locked_context=locked_context + ) 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) + # Retry with a smaller tail but still honoring tail_chars if set + rt = ramble_text[-min(self.tail_chars or 3000, 3000):] + self._log("Retrying with smaller prompt…") + shorter = self._build_prompt( + user_prompt=prompt, ramble_text=rt, + fields=fields, field_criteria=field_criteria, locked_context=locked_context + ) out = self._run_once(shorter, timeout=max(45, self.timeout_s // 2)) txt = self._strip_fences(out) @@ -206,7 +271,7 @@ class ClaudeCLIProvider: return self._normalize(data, fields) -# --- Helpers: Fade label + fading ramble edit --------------------------------- +# ── UI helpers ───────────────────────────────────────────────────────────────── class FadeLabel(QLabel): def __init__(self, parent=None): super().__init__(parent) @@ -214,43 +279,37 @@ class FadeLabel(QLabel): 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() + 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.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.setPlaceholderText("Start here. Ramble about your idea…") 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) + font = QFont("DejaVu Sans Mono"); font.setPointSize(11) self.setFont(font) - self.setFixedHeight(180) - - def wheelEvent(self, event): # prevent manual scrolling - event.ignore() - + self.setFixedHeight(190) + # Soft highlight + self.setStyleSheet(""" + QPlainTextEdit { + background:#f4fbff; border:2px solid #9ed6ff; border-radius:8px; padding:8px; + } + """) + def wheelEvent(self, event): event.ignore() def keyPressEvent(self, event): - super().keyPressEvent(event) - self.moveCursor(QTextCursor.End) - + super().keyPressEvent(event); self.moveCursor(QTextCursor.End) def paintEvent(self, e): super().paintEvent(e) painter = QPainter(self.viewport()) @@ -262,77 +321,60 @@ class FadingRambleEdit(QPlainTextEdit): painter.end() -# --- Image backends ------------------------------------------------------------ +# ── Images (optional 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") + raise RuntimeError("The 'requests' library is required for image fetching. 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") + 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 + 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") + 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 + 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() + if not raw_url: return None + img = requests.get(raw_url, timeout=60); img.raise_for_status() return img.content -# --- PlantUML rendering -------------------------------------------------------- +# ── PlantUML ────────────────────────────────────────────────────────────────── 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).") + if not exe: raise RuntimeError("plantuml not found in PATH. 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, - ) + 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]}") + raise RuntimeError(f"plantuml failed: {proc.stderr.decode('utf-8','ignore')[:200]}") return proc.stdout -# --- Background worker --------------------------------------------------------- +# ── Background worker ───────────────────────────────────────────────────────── class GenWorker(QRunnable): class Signals(QObject): - finished = Signal(dict) - error = Signal(str) + finished = Signal(dict); error = Signal(str) def __init__(self, provider: RambleProvider, payload: Dict[str, Any]): - super().__init__() - self.provider = provider - self.payload = payload + super().__init__(); self.provider = provider; self.payload = payload self.signals = GenWorker.Signals() def run(self): try: @@ -342,13 +384,11 @@ class GenWorker(QRunnable): self.signals.error.emit(str(e)) # type: ignore -# --- Main Dialog --------------------------------------------------------------- +# ── 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__( @@ -356,6 +396,7 @@ class RambleDialog(QDialog): *, prompt: str, fields: List[str], + field_criteria: Optional[Dict[str,str]] = None, hints: Optional[List[str]] = None, provider: Optional[RambleProvider] = None, enable_stability: bool = False, @@ -364,82 +405,79 @@ class RambleDialog(QDialog): ): super().__init__(parent) self.setWindowTitle("Ramble → Generate") - self.resize(1100, 750) # sensible default, scroll keeps it usable + self.resize(1120, 760) self.provider = provider or cast(RambleProvider, MockProvider()) self._prompt_text = prompt - self._fields = fields[:] + self._fields = fields[:] if fields else ["Summary"] + if "Summary" not in self._fields: + self._fields.insert(0, "Summary") + self._criteria = field_criteria or {} + # sensible default for Summary if not provided + self._criteria.setdefault("Summary", "<= 2 sentences") + 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?", + "What problem does it solve?", + "What would success look like?", ] self.enable_stability = enable_stability - self.enable_pexels = enable_pexels and not enable_stability # stability takes precedence + self.enable_pexels = enable_pexels and not enable_stability 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} + self.field_lock_boxes: Dict[str, QCheckBox] = {} + self.field_outputs: Dict[str, QTextEdit] = {} - # --- Outer layout: scrollable content + fixed footer row + # Layout: scrollable content + fixed footer outer = QVBoxLayout(self) - # Scroll area wraps the main content - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - content = QWidget() - self.scroll.setWidget(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() + # “How to use” toggle + self.help_btn = QPushButton("How to use ▾"); self.help_btn.setCheckable(True); self.help_btn.setChecked(False) + self.help_btn.clicked.connect(self._toggle_help) # type: ignore + footer.addWidget(self.help_btn) footer.addStretch(1) - self.submit_btn = QPushButton("Submit") - self.submit_btn.clicked.connect(self.on_submit) # type: ignore + 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) + 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 + # Title & hint + title = QLabel("Ramble about your idea. Fields will fill themselves.") + title.setWordWrap(True) + grid.addWidget(title, 0, 0, 1, 2) + self.hint_label = FadeLabel(); self.hint_label.setText(self._hints[0]) + self.hint_label.setStyleSheet("color:#666; font-style:italic;") + grid.addWidget(self.hint_label, 1, 0, 1, 2) - # Left column ----------------------------------------------------------- + # Left column: Ramble → Generate → UML → Images left_col = QVBoxLayout() - # Ramble input - self.ramble_edit = FadingRambleEdit(max_blocks=600) + self.ramble_edit = FadingRambleEdit(max_blocks=900) left_col.addWidget(self.ramble_edit) - # Generate button right under input gen_row = QHBoxLayout() self.generate_btn = QPushButton("Generate / Update") + self.generate_btn.setStyleSheet("QPushButton { background:#e6f4ff; border:1px solid #9ed6ff; padding:6px 12px; }") 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) + 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) + # UML (image only) + uml_group = QGroupBox("Diagram (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: @@ -448,70 +486,63 @@ class RambleDialog(QDialog): 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_group = QGroupBox("Images / Descriptions"); img_v = QVBoxLayout(img_group) + self.img_desc_text = QTextEdit(); self.img_desc_text.setReadOnly(True); self.img_desc_text.setMinimumHeight(80) + self.img_desc_text.setPlaceholderText("Descriptions of suggested 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 column: Fields 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") + fields_group = QGroupBox("Fields") + fields_group.setStyleSheet("QGroupBox { background:#fafafa; border:1px solid #ddd; border-radius:8px; margin-top:6px; }") 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("") + widget.setStyleSheet("background-color: #fff7cc;" if locked else "") for f in self._fields: row = QHBoxLayout() - te = QTextEdit() - te.setReadOnly(False) # always editable - te.setMinimumHeight(70) + te = QTextEdit(); te.setReadOnly(False); te.setMinimumHeight(64) te.setPlaceholderText(f"{f}…") self.field_outputs[f] = te - - lock = QCheckBox("Lock") - lock.setChecked(False) + 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) + # show criteria hint (subtle) + crit = self._criteria.get(f, "") + if crit: + hint = QLabel(f"({crit})") + else: + hint = QLabel("") + row.addWidget(te, 1); row.addWidget(lock) + fg_form.addRow(QLabel(f"{f}:"), row) + fg_form.addRow(hint) apply_lock_style(te, False) right_col.addWidget(fields_group, 1) - # Status label at the very bottom of content (inside scroll) + # Inline help panel (hidden by default) + self.help_panel = QLabel( + "How to use:\n" + "• Start in the blue box above and just ramble about your idea.\n" + "• Click “Generate / Update” to fill fields automatically.\n" + "• Not happy? Add more detail to your ramble or edit fields directly.\n" + "• Love a field? Tick “Lock” so it won’t change next time. Locked fields also help guide the next generation.\n" + "• When satisfied, click Submit to output your structured JSON." + ) + self.help_panel.setWordWrap(True) + self.help_panel.setVisible(False) + self.help_panel.setStyleSheet("background:#f8fbff; border:1px dashed #bcdcff; padding:8px; border-radius:6px;") + right_col.addWidget(self.help_panel) + + # Status self.status_label = QLabel(""); self.status_label.setStyleSheet("color:#777;") right_col.addWidget(self.status_label) - # Place columns into the grid + # Place columns left_w = QWidget(); left_w.setLayout(left_col) right_w = QWidget(); right_w.setLayout(right_col) grid.addWidget(left_w, 2, 0) @@ -519,10 +550,13 @@ class RambleDialog(QDialog): self._setup_hint_timer() + def _toggle_help(self): + self.help_panel.setVisible(self.help_btn.isChecked()) + self.help_btn.setText("How to use ▴" if self.help_btn.isChecked() else "How to use ▾") + def _setup_hint_timer(self): self._hint_idx = 0 - self._hint_timer = QTimer(self) - self._hint_timer.setInterval(3500) + self._hint_timer = QTimer(self); self._hint_timer.setInterval(3500) self._hint_timer.timeout.connect(self._advance_hint) # type: ignore self._hint_timer.start() @@ -531,7 +565,6 @@ class RambleDialog(QDialog): 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() @@ -539,25 +572,41 @@ class RambleDialog(QDialog): QMessageBox.information(self, "Nothing to generate", "Type your thoughts first, then click Generate / Update.") return + # Collect locked fields to feed back as authoritative context + locked_context: Dict[str, str] = {} + for name, box in self.field_lock_boxes.items(): + if box.isChecked(): + locked_context[name] = self.field_outputs[name].toPlainText().strip() + + payload = { + "prompt": self._prompt_text, + "ramble_text": ramble_text, + "fields": self._fields, + "field_criteria": self._criteria, + "locked_context": locked_context, + } + 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 + # 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 + if self.field_lock_boxes[name].isChecked(): + continue widget.setPlainText(new_fields.get(name, "")) - # UML: render PlantUML if provider didn't supply PNG bytes (show image only) + # Also update Summary if present and not locked + if "Summary" in self.field_outputs and not self.field_lock_boxes["Summary"].isChecked(): + # If provider also returns top-level "summary", prefer that + s = data.get("summary", "") or new_fields.get("Summary", "") + if s: self.field_outputs["Summary"].setPlainText(s) + + # UML render (first diagram) uml_blocks = data.get("uml_blocks", []) self.uml_image_label.clear() for (uml_text, maybe_png) in uml_blocks: @@ -572,71 +621,14 @@ class RambleDialog(QDialog): pix = QPixmap() if pix.loadFromData(png_bytes): self.uml_image_label.setPixmap(pix) - break # show first image only + break # 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) @@ -648,21 +640,19 @@ class RambleDialog(QDialog): @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(), - ) + out_fields = {k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()} + # prefer explicit Summary field if present + summary = out_fields.get("Summary", "").strip() + self.result = RambleResult(summary=summary, fields=out_fields) self.accept() -# --- Public API --------------------------------------------------------------- +# ── Public API ──────────────────────────────────────────────────────────────── def open_ramble_dialog( *, prompt: str, fields: List[str], + field_criteria: Optional[Dict[str, str]] = None, hints: Optional[List[str]] = None, provider: Optional[RambleProvider] = None, enable_stability: bool = False, @@ -678,6 +668,7 @@ def open_ramble_dialog( dlg = RambleDialog( prompt=prompt, fields=fields, + field_criteria=field_criteria, hints=hints, provider=provider, enable_stability=enable_stability, @@ -691,9 +682,6 @@ def open_ramble_dialog( 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: @@ -701,7 +689,7 @@ def open_ramble_dialog( return out -# --- CLI demo ------------------------------------------------------------------ +# ── CLI demo ────────────────────────────────────────────────────────────────── def parse_args(): p = argparse.ArgumentParser(description="Ramble → Generate (PlantUML + optional images)") p.add_argument("--provider", choices=["mock", "claude"], default="mock") @@ -709,7 +697,8 @@ def parse_args(): 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("--fields", nargs="+", default=["Summary","Title","Intent","ProblemItSolves","BriefOverview"]) + p.add_argument("--criteria", default="", help="JSON mapping of field -> criteria") p.add_argument("--timeout", type=int, default=90) p.add_argument("--tail", type=int, default=6000) p.add_argument("--debug", action="store_true") @@ -718,35 +707,40 @@ def parse_args(): if __name__ == "__main__": args = parse_args() + # Field criteria parsing + criteria: Dict[str, str] = {} + if args.criteria.strip(): + try: + criteria = json.loads(args.criteria) + except Exception as e: + print(f"[WARN] Could not parse --criteria JSON: {e}", file=sys.stderr) + + # Provider 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, + 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) + # Ensure PlantUML 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) + print("[FATAL] 'requests' is required for image backends. pip install requests", file=sys.stderr) sys.exit(3) demo = open_ramble_dialog( prompt=args.prompt, fields=args.fields, + field_criteria=criteria, 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))