1st commit

This commit is contained in:
rob 2025-10-22 14:25:04 -03:00
parent e4174218ed
commit 6f6c66891f
1 changed files with 270 additions and 276 deletions

546
ramble.py
View File

@ -1,15 +1,36 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
r""" 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) Whats new:
- Fields are always editable (no Edit button) - Locked fields feed back into the prompt as authoritative context.
- Lock via checkbox; locked fields are highlighted and not overwritten by Generate - Summary is just another field; all fields can have custom criteria.
- Hide UML code; only show rendered PlantUML image - Fields are always editable; Lock checkbox prevents overwrite and highlights the field.
- Scrollable main content so long generations fit smaller screens - How to use toggle with simple instructions.
- "Generate / Update" moved under the Ramble input - Soft colors; Ramble input visually stands out.
- Bottom-right "Submit" button returns JSON (summary + fields) via the public API and prints in CLI demo - 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 from __future__ import annotations
@ -20,9 +41,9 @@ import os, sys, json, textwrap, base64, re, time, shutil, subprocess, argparse,
try: try:
import requests # type: ignore import requests # type: ignore
except ImportError: except ImportError:
requests = None # runtime-guarded requests = None
# --- Qt Import (PySide6 preferred; fallback to PyQt5) ------------------------- # ── Qt (PySide6 preferred; PyQt5 fallback) ────────────────────────────────────
QT_LIB = None QT_LIB = None
try: try:
from PySide6.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject, from PySide6.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject,
@ -31,7 +52,7 @@ try:
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy, QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy,
QFrame, QMessageBox, QScrollArea, QCheckBox, QSpacerItem QFrame, QMessageBox, QScrollArea, QCheckBox
) )
QT_LIB = "PySide6" QT_LIB = "PySide6"
except ImportError: except ImportError:
@ -41,32 +62,54 @@ except ImportError:
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy, QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy,
QFrame, QMessageBox, QScrollArea, QCheckBox, QSpacerItem QFrame, QMessageBox, QScrollArea, QCheckBox
) )
QT_LIB = "PyQt5" QT_LIB = "PyQt5"
# --- Provider Protocol --------------------------------------------------------- # ── Provider Protocol ─────────────────────────────────────────────────────────
@runtime_checkable @runtime_checkable
class RambleProvider(Protocol): 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: 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() words = ramble_text.strip().split()
cap = min(25, len(words)) cap = min(25, len(words))
summary = " ".join(words[-cap:]) if words else "(no content yet)" summary = " ".join(words[-cap:]) if words else "(no content yet)"
summary = (summary[:1].upper() + summary[1:]).rstrip(".") + "." 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 = [ uml_blocks = [
("@startuml\nactor User\nUser -> System: Ramble\nSystem -> LLM: Generate\nLLM --> System: Summary/Fields/UML\nSystem --> User: Updates\n@enduml", None) ("@startuml\nactor User\nUser -> System: Ramble\nSystem -> LLM: Generate\nLLM --> System: Summary/Fields/UML\nSystem --> User: Updates\n@enduml", None)
] ]
image_descriptions = [ image_descriptions = [
"A conceptual sketch representing the core entities mentioned in the ramble.", "Illustrate the core actor interacting with the system.",
"An abstract icon related to the domain of the idea." "Abstract icon that represents the ideas domain."
] ]
return { return {
"summary": summary, "summary": summary,
@ -76,7 +119,7 @@ class MockProvider:
} }
# --- Claude CLI Provider ------------------------------------------------------- # ── Claude CLI Provider ───────────────────────────────────────────────────────
class ClaudeCLIProvider: class ClaudeCLIProvider:
def __init__( def __init__(
self, self,
@ -101,25 +144,41 @@ class ClaudeCLIProvider:
with open(self.log_path, "a", encoding="utf-8") as f: with open(self.log_path, "a", encoding="utf-8") as f:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", file=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: def _build_prompt(
fields_list = "\n".join(f'- "{f}"' for f in fields) 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 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"""\ return textwrap.dedent(f"""\
You are an assistant that returns ONLY compact JSON. No preamble, no markdown, no code fences. You are an assistant that returns ONLY compact JSON. No preamble, no markdown, no code fences.
Task: Goal:
1) Read the user's 'ramble' text. - Read the user's "ramble" and synthesize structured outputs.
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: Required JSON keys:
- Prompt: {user_prompt} - "summary": string
- Ramble (tail, possibly truncated): - "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 37 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} {ramble_tail}
""").strip() + "\n" """).strip() + "\n"
@ -138,21 +197,15 @@ class ClaudeCLIProvider:
else: else:
argv = [self.cmd, *self.extra_args] argv = [self.cmd, *self.extra_args]
stdin = prompt_text stdin = prompt_text
self._log(f"argv: {argv}") self._log(f"argv: {argv}")
t0 = time.time() t0 = time.time()
try: try:
proc = subprocess.run( proc = subprocess.run(argv, input=stdin, capture_output=True, text=True, timeout=timeout, check=False)
argv, input=stdin, capture_output=True, text=True,
timeout=timeout, check=False,
)
except FileNotFoundError as e: except FileNotFoundError as e:
self._log(f"FileNotFoundError: {e}") self._log(f"FileNotFoundError: {e}")
raise RuntimeError(f"Claude CLI not found at {self.cmd}.") from e raise RuntimeError(f"Claude CLI not found at {self.cmd}.") from e
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
self._log("TimeoutExpired") self._log("TimeoutExpired"); raise
raise
out = (proc.stdout or "").strip() out = (proc.stdout or "").strip()
err = (proc.stderr 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") 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], "image_descriptions": [str(s) for s in image_desc],
} }
def generate(self, *, prompt: str, ramble_text: str, fields: List[str]) -> Dict[str, Any]: def generate(
p = self._build_prompt(user_prompt=prompt, ramble_text=ramble_text, fields=fields) 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: try:
out = self._run_once(p, timeout=self.timeout_s) out = self._run_once(p, timeout=self.timeout_s)
except subprocess.TimeoutExpired: 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)) out = self._run_once(shorter, timeout=max(45, self.timeout_s // 2))
txt = self._strip_fences(out) txt = self._strip_fences(out)
@ -206,7 +271,7 @@ class ClaudeCLIProvider:
return self._normalize(data, fields) return self._normalize(data, fields)
# --- Helpers: Fade label + fading ramble edit --------------------------------- # ── UI helpers ─────────────────────────────────────────────────────────────────
class FadeLabel(QLabel): class FadeLabel(QLabel):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@ -214,43 +279,37 @@ class FadeLabel(QLabel):
self._anim.setDuration(300) self._anim.setDuration(300)
self._anim.setEasingCurve(QEasingCurve.InOutQuad) self._anim.setEasingCurve(QEasingCurve.InOutQuad)
self.setWindowOpacity(1.0) self.setWindowOpacity(1.0)
def fade_to_text(self, text: str): def fade_to_text(self, text: str):
def set_text(): def set_text():
self.setText(text) self.setText(text)
try: try: self._anim.finished.disconnect(set_text) # type: ignore
self._anim.finished.disconnect(set_text) # type: ignore except Exception: pass
except Exception: self._anim.setStartValue(0.0); self._anim.setEndValue(1.0); self._anim.start()
pass
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
self._anim.start()
self._anim.stop() self._anim.stop()
self._anim.setStartValue(1.0) self._anim.setStartValue(1.0); self._anim.setEndValue(0.0)
self._anim.setEndValue(0.0)
self._anim.finished.connect(set_text) # type: ignore self._anim.finished.connect(set_text) # type: ignore
self._anim.start() self._anim.start()
class FadingRambleEdit(QPlainTextEdit): class FadingRambleEdit(QPlainTextEdit):
def __init__(self, max_blocks: int = 500, parent=None): def __init__(self, max_blocks: int = 500, parent=None):
super().__init__(parent) super().__init__(parent)
self.setPlaceholderText("Type freely… just keep rambling.") self.setPlaceholderText("Start here. Ramble about your idea…")
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setFrameStyle(QFrame.StyledPanel | QFrame.Raised) self.setFrameStyle(QFrame.StyledPanel | QFrame.Raised)
self.document().setMaximumBlockCount(max_blocks) 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.setFont(font)
self.setFixedHeight(180) self.setFixedHeight(190)
# Soft highlight
def wheelEvent(self, event): # prevent manual scrolling self.setStyleSheet("""
event.ignore() QPlainTextEdit {
background:#f4fbff; border:2px solid #9ed6ff; border-radius:8px; padding:8px;
}
""")
def wheelEvent(self, event): event.ignore()
def keyPressEvent(self, event): def keyPressEvent(self, event):
super().keyPressEvent(event) super().keyPressEvent(event); self.moveCursor(QTextCursor.End)
self.moveCursor(QTextCursor.End)
def paintEvent(self, e): def paintEvent(self, e):
super().paintEvent(e) super().paintEvent(e)
painter = QPainter(self.viewport()) painter = QPainter(self.viewport())
@ -262,77 +321,60 @@ class FadingRambleEdit(QPlainTextEdit):
painter.end() painter.end()
# --- Image backends ------------------------------------------------------------ # ── Images (optional backends) ────────────────────────────────────────────────
def have_requests_or_raise(): def have_requests_or_raise():
if requests is None: 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]: def fetch_image_stability(prompt: str) -> Optional[bytes]:
have_requests_or_raise(); assert requests is not None have_requests_or_raise(); assert requests is not None
api_key = os.getenv("STABILITY_API_KEY", "").strip() api_key = os.getenv("STABILITY_API_KEY", "").strip()
if not api_key: if not api_key: raise RuntimeError("STABILITY_API_KEY is not set")
raise RuntimeError("STABILITY_API_KEY is not set")
url = "https://api.stability.ai/v2beta/stable-image/generate/core" url = "https://api.stability.ai/v2beta/stable-image/generate/core"
headers = {"Authorization": f"Bearer {api_key}", "Accept": "image/png"} headers = {"Authorization": f"Bearer {api_key}", "Accept": "image/png"}
data = {"prompt": prompt[:1000], "output_format": "png"} data = {"prompt": prompt[:1000], "output_format": "png"}
resp = requests.post(url, headers=headers, data=data, timeout=60) resp = requests.post(url, headers=headers, data=data, timeout=60)
if resp.status_code == 200 and resp.content: if resp.status_code == 200 and resp.content: return resp.content
return resp.content
raise RuntimeError(f"Stability API error: {resp.status_code} {resp.text[:200]}") raise RuntimeError(f"Stability API error: {resp.status_code} {resp.text[:200]}")
def fetch_image_pexels(query: str) -> Optional[bytes]: def fetch_image_pexels(query: str) -> Optional[bytes]:
have_requests_or_raise(); assert requests is not None have_requests_or_raise(); assert requests is not None
api_key = os.getenv("PEXELS_API_KEY", "").strip() api_key = os.getenv("PEXELS_API_KEY", "").strip()
if not api_key: if not api_key: raise RuntimeError("PEXELS_API_KEY is not set")
raise RuntimeError("PEXELS_API_KEY is not set")
s_url = "https://api.pexels.com/v1/search" s_url = "https://api.pexels.com/v1/search"
headers = {"Authorization": api_key} headers = {"Authorization": api_key}
params = {"query": query, "per_page": 1} params = {"query": query, "per_page": 1}
r = requests.get(s_url, headers=headers, params=params, timeout=30) r = requests.get(s_url, headers=headers, params=params, timeout=30); r.raise_for_status()
r.raise_for_status() data = r.json(); photos = data.get("photos") or []
data = r.json() if not photos: return None
photos = data.get("photos") or []
if not photos:
return None
src = photos[0].get("src") or {} src = photos[0].get("src") or {}
raw_url = src.get("original") or src.get("large") or src.get("medium") raw_url = src.get("original") or src.get("large") or src.get("medium")
if not raw_url: if not raw_url: return None
return None img = requests.get(raw_url, timeout=60); img.raise_for_status()
img = requests.get(raw_url, timeout=60)
img.raise_for_status()
return img.content return img.content
# --- PlantUML rendering -------------------------------------------------------- # ── PlantUML ──────────────────────────────────────────────────────────────────
def ensure_plantuml_present(): def ensure_plantuml_present():
exe = shutil.which("plantuml") exe = shutil.which("plantuml")
if not exe: if not exe: raise RuntimeError("plantuml not found in PATH. sudo apt install plantuml")
raise RuntimeError("plantuml not found in PATH. Install it (e.g., sudo apt install plantuml).")
return exe return exe
def render_plantuml_to_png_bytes(uml_text: str) -> bytes: def render_plantuml_to_png_bytes(uml_text: str) -> bytes:
exe = ensure_plantuml_present() exe = ensure_plantuml_present()
proc = subprocess.run( proc = subprocess.run([exe, "-tpng", "-pipe"], input=uml_text.encode("utf-8"),
[exe, "-tpng", "-pipe"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
input=uml_text.encode("utf-8"),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if proc.returncode != 0 or not proc.stdout: 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 return proc.stdout
# --- Background worker --------------------------------------------------------- # ── Background worker ─────────────────────────────────────────────────────────
class GenWorker(QRunnable): class GenWorker(QRunnable):
class Signals(QObject): class Signals(QObject):
finished = Signal(dict) finished = Signal(dict); error = Signal(str)
error = Signal(str)
def __init__(self, provider: RambleProvider, payload: Dict[str, Any]): def __init__(self, provider: RambleProvider, payload: Dict[str, Any]):
super().__init__() super().__init__(); self.provider = provider; self.payload = payload
self.provider = provider
self.payload = payload
self.signals = GenWorker.Signals() self.signals = GenWorker.Signals()
def run(self): def run(self):
try: try:
@ -342,13 +384,11 @@ class GenWorker(QRunnable):
self.signals.error.emit(str(e)) # type: ignore self.signals.error.emit(str(e)) # type: ignore
# --- Main Dialog --------------------------------------------------------------- # ── Dialog ────────────────────────────────────────────────────────────────────
@dataclass @dataclass
class RambleResult: class RambleResult:
summary: str summary: str
fields: Dict[str, str] fields: Dict[str, str]
uml_blocks: List[Tuple[str, Optional[bytes]]]
image_descriptions: List[str]
class RambleDialog(QDialog): class RambleDialog(QDialog):
def __init__( def __init__(
@ -356,6 +396,7 @@ class RambleDialog(QDialog):
*, *,
prompt: str, prompt: str,
fields: List[str], fields: List[str],
field_criteria: Optional[Dict[str,str]] = None,
hints: Optional[List[str]] = None, hints: Optional[List[str]] = None,
provider: Optional[RambleProvider] = None, provider: Optional[RambleProvider] = None,
enable_stability: bool = False, enable_stability: bool = False,
@ -364,82 +405,79 @@ class RambleDialog(QDialog):
): ):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Ramble → Generate") 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.provider = provider or cast(RambleProvider, MockProvider())
self._prompt_text = prompt 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 [ self._hints = hints or [
"What is it called?", "What is it called?",
"What is it for?",
"What type of idea is it?",
"What problem does it solve?",
"Who benefits most?", "Who benefits most?",
"What are success criteria?", "What problem does it solve?",
"What would success look like?",
] ]
self.enable_stability = enable_stability 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.thread_pool = QThreadPool.globalInstance()
self.result: Optional[RambleResult] = None self.result: Optional[RambleResult] = None
# Track per-field lock states # 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) outer = QVBoxLayout(self)
# Scroll area wraps the main content self.scroll = QScrollArea(); self.scroll.setWidgetResizable(True)
self.scroll = QScrollArea() content = QWidget(); self.scroll.setWidget(content)
self.scroll.setWidgetResizable(True)
content = QWidget()
self.scroll.setWidget(content)
outer.addWidget(self.scroll, 1) outer.addWidget(self.scroll, 1)
# Footer with Submit button (fixed)
footer = QHBoxLayout() 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) footer.addStretch(1)
self.submit_btn = QPushButton("Submit") self.submit_btn = QPushButton("Submit"); self.submit_btn.clicked.connect(self.on_submit) # type: ignore
self.submit_btn.clicked.connect(self.on_submit) # type: ignore
footer.addWidget(self.submit_btn) footer.addWidget(self.submit_btn)
outer.addLayout(footer) outer.addLayout(footer)
# === Inside the scrollable content ===
grid = QGridLayout(content) grid = QGridLayout(content)
grid.setHorizontalSpacing(16) grid.setHorizontalSpacing(16); grid.setVerticalSpacing(12)
grid.setVerticalSpacing(12)
# Top prompt + hint # Title & hint
prompt_label = QLabel(f"<b>{self._prompt_text}</b>"); prompt_label.setWordWrap(True) title = QLabel("<b>Ramble about your idea. Fields will fill themselves.</b>")
hint_label = FadeLabel(); hint_label.setText(self._hints[0]) title.setWordWrap(True)
hint_label.setStyleSheet("color:#666; font-style:italic;") grid.addWidget(title, 0, 0, 1, 2)
grid.addWidget(prompt_label, 0, 0, 1, 2) self.hint_label = FadeLabel(); self.hint_label.setText(self._hints[0])
grid.addWidget(hint_label, 1, 0, 1, 2) self.hint_label.setStyleSheet("color:#666; font-style:italic;")
self.hint_label = hint_label grid.addWidget(self.hint_label, 1, 0, 1, 2)
# Left column ----------------------------------------------------------- # Left column: Ramble → Generate → UML → Images
left_col = QVBoxLayout() left_col = QVBoxLayout()
# Ramble input self.ramble_edit = FadingRambleEdit(max_blocks=900)
self.ramble_edit = FadingRambleEdit(max_blocks=600)
left_col.addWidget(self.ramble_edit) left_col.addWidget(self.ramble_edit)
# Generate button right under input
gen_row = QHBoxLayout() gen_row = QHBoxLayout()
self.generate_btn = QPushButton("Generate / Update") 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.generate_btn.clicked.connect(self.on_generate) # type: ignore
self.provider_label = QLabel(f"Provider: {type(self.provider).__name__}") self.provider_label = QLabel(f"Provider: {type(self.provider).__name__}")
self.provider_label.setStyleSheet("color:#555;") self.provider_label.setStyleSheet("color:#555;")
gen_row.addWidget(self.generate_btn) gen_row.addWidget(self.generate_btn); gen_row.addStretch(1); gen_row.addWidget(self.provider_label)
gen_row.addStretch(1)
gen_row.addWidget(self.provider_label)
left_col.addLayout(gen_row) left_col.addLayout(gen_row)
# UML group (image only) # UML (image only)
uml_group = QGroupBox("Generated UML (PlantUML)") uml_group = QGroupBox("Diagram (PlantUML)"); uml_v = QVBoxLayout(uml_group)
uml_v = QVBoxLayout(uml_group) self.uml_image_label = QLabel(); self.uml_image_label.setAlignment(Qt.AlignCenter)
self.uml_image_label = QLabel()
self.uml_image_label.setAlignment(Qt.AlignCenter)
if QT_LIB == "PyQt5": if QT_LIB == "PyQt5":
self.uml_image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.uml_image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else: else:
@ -448,70 +486,63 @@ class RambleDialog(QDialog):
left_col.addWidget(uml_group) left_col.addWidget(uml_group)
# Images / descriptions # Images / descriptions
img_group = QGroupBox("Generated Images / Descriptions") img_group = QGroupBox("Images / Descriptions"); img_v = QVBoxLayout(img_group)
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 = QTextEdit(); self.img_desc_text.setReadOnly(True); self.img_desc_text.setMinimumHeight(90) self.img_desc_text.setPlaceholderText("Descriptions of suggested images…")
self.img_desc_text.setPlaceholderText("Descriptions of suggested/related images…")
img_v.addWidget(self.img_desc_text) 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) left_col.addWidget(img_group)
# Right column ---------------------------------------------------------- # Right column: Fields
right_col = QVBoxLayout() right_col = QVBoxLayout()
# Summary (editable) fields_group = QGroupBox("Fields")
summary_group = QGroupBox("Generated Summary") fields_group.setStyleSheet("QGroupBox { background:#fafafa; border:1px solid #ddd; border-radius:8px; margin-top:6px; }")
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) 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): def apply_lock_style(widget: QTextEdit, locked: bool):
if locked: widget.setStyleSheet("background-color: #fff7cc;" if locked else "")
widget.setStyleSheet("background-color: #fff7cc;") # light yellow
else:
widget.setStyleSheet("")
for f in self._fields: for f in self._fields:
row = QHBoxLayout() row = QHBoxLayout()
te = QTextEdit() te = QTextEdit(); te.setReadOnly(False); te.setMinimumHeight(64)
te.setReadOnly(False) # always editable
te.setMinimumHeight(70)
te.setPlaceholderText(f"{f}") te.setPlaceholderText(f"{f}")
self.field_outputs[f] = te self.field_outputs[f] = te
lock = QCheckBox("Lock"); lock.setChecked(False)
lock = QCheckBox("Lock")
lock.setChecked(False)
self.field_lock_boxes[f] = lock 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 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) # show criteria hint (subtle)
row.addWidget(lock) crit = self._criteria.get(f, "")
fg_form.addRow(f"{f}:", row) if crit:
hint = QLabel(f"<span style='color:#777; font-size:11px'>({crit})</span>")
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) apply_lock_style(te, False)
right_col.addWidget(fields_group, 1) 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 wont 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;") self.status_label = QLabel(""); self.status_label.setStyleSheet("color:#777;")
right_col.addWidget(self.status_label) right_col.addWidget(self.status_label)
# Place columns into the grid # Place columns
left_w = QWidget(); left_w.setLayout(left_col) left_w = QWidget(); left_w.setLayout(left_col)
right_w = QWidget(); right_w.setLayout(right_col) right_w = QWidget(); right_w.setLayout(right_col)
grid.addWidget(left_w, 2, 0) grid.addWidget(left_w, 2, 0)
@ -519,10 +550,13 @@ class RambleDialog(QDialog):
self._setup_hint_timer() 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): def _setup_hint_timer(self):
self._hint_idx = 0 self._hint_idx = 0
self._hint_timer = QTimer(self) self._hint_timer = QTimer(self); self._hint_timer.setInterval(3500)
self._hint_timer.setInterval(3500)
self._hint_timer.timeout.connect(self._advance_hint) # type: ignore self._hint_timer.timeout.connect(self._advance_hint) # type: ignore
self._hint_timer.start() self._hint_timer.start()
@ -531,7 +565,6 @@ class RambleDialog(QDialog):
self._hint_idx = (self._hint_idx + 1) % len(self._hints) self._hint_idx = (self._hint_idx + 1) % len(self._hints)
self.hint_label.fade_to_text(self._hints[self._hint_idx]) self.hint_label.fade_to_text(self._hints[self._hint_idx])
# Actions -------------------------------------------------------------------
@Slot() @Slot()
def on_generate(self): def on_generate(self):
ramble_text = self.ramble_edit.toPlainText() 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.") QMessageBox.information(self, "Nothing to generate", "Type your thoughts first, then click Generate / Update.")
return 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…") self._set_busy(True, "Generating…")
payload = {"prompt": self._prompt_text, "ramble_text": ramble_text, "fields": self._fields}
worker = GenWorker(self.provider, payload) worker = GenWorker(self.provider, payload)
worker.signals.finished.connect(self._on_generated) # type: ignore worker.signals.finished.connect(self._on_generated) # type: ignore
worker.signals.error.connect(self._on_gen_error) # type: ignore worker.signals.error.connect(self._on_gen_error) # type: ignore
self.thread_pool.start(worker) self.thread_pool.start(worker)
def _on_generated(self, data: Dict[str, Any]): 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) # Respect locks
self.summary_edit.setPlainText(data.get("summary", ""))
# Fields: respect locks
new_fields: Dict[str, str] = data.get("fields", {}) new_fields: Dict[str, str] = data.get("fields", {})
for name, widget in self.field_outputs.items(): for name, widget in self.field_outputs.items():
if self.field_lock_boxes.get(name, QCheckBox()).isChecked(): if self.field_lock_boxes[name].isChecked():
continue # keep user's text continue
widget.setPlainText(new_fields.get(name, "")) 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", []) uml_blocks = data.get("uml_blocks", [])
self.uml_image_label.clear() self.uml_image_label.clear()
for (uml_text, maybe_png) in uml_blocks: for (uml_text, maybe_png) in uml_blocks:
@ -572,71 +621,14 @@ class RambleDialog(QDialog):
pix = QPixmap() pix = QPixmap()
if pix.loadFromData(png_bytes): if pix.loadFromData(png_bytes):
self.uml_image_label.setPixmap(pix) self.uml_image_label.setPixmap(pix)
break # show first image only break
# Images / descriptions # Images / descriptions
img_desc = [str(s) for s in data.get("image_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.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.") 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): def _on_gen_error(self, msg: str):
self._set_busy(False, "Error.") self._set_busy(False, "Error.")
QMessageBox.critical(self, "Generation Error", msg) QMessageBox.critical(self, "Generation Error", msg)
@ -648,21 +640,19 @@ class RambleDialog(QDialog):
@Slot() @Slot()
def on_submit(self): def on_submit(self):
"""Collect current summary + fields and return them to caller.""" out_fields = {k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()}
self.result = RambleResult( # prefer explicit Summary field if present
summary=self.summary_edit.toPlainText(), summary = out_fields.get("Summary", "").strip()
fields={k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()}, self.result = RambleResult(summary=summary, fields=out_fields)
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() self.accept()
# --- Public API --------------------------------------------------------------- # ── Public API ────────────────────────────────────────────────────────────────
def open_ramble_dialog( def open_ramble_dialog(
*, *,
prompt: str, prompt: str,
fields: List[str], fields: List[str],
field_criteria: Optional[Dict[str, str]] = None,
hints: Optional[List[str]] = None, hints: Optional[List[str]] = None,
provider: Optional[RambleProvider] = None, provider: Optional[RambleProvider] = None,
enable_stability: bool = False, enable_stability: bool = False,
@ -678,6 +668,7 @@ def open_ramble_dialog(
dlg = RambleDialog( dlg = RambleDialog(
prompt=prompt, prompt=prompt,
fields=fields, fields=fields,
field_criteria=field_criteria,
hints=hints, hints=hints,
provider=provider, provider=provider,
enable_stability=enable_stability, enable_stability=enable_stability,
@ -691,9 +682,6 @@ def open_ramble_dialog(
out = { out = {
"summary": dlg.result.summary, "summary": dlg.result.summary,
"fields": dlg.result.fields, "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: if app_created:
@ -701,7 +689,7 @@ def open_ramble_dialog(
return out return out
# --- CLI demo ------------------------------------------------------------------ # ── CLI demo ──────────────────────────────────────────────────────────────────
def parse_args(): def parse_args():
p = argparse.ArgumentParser(description="Ramble → Generate (PlantUML + optional images)") p = argparse.ArgumentParser(description="Ramble → Generate (PlantUML + optional images)")
p.add_argument("--provider", choices=["mock", "claude"], default="mock") 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("--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("--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("--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("--timeout", type=int, default=90)
p.add_argument("--tail", type=int, default=6000) p.add_argument("--tail", type=int, default=6000)
p.add_argument("--debug", action="store_true") p.add_argument("--debug", action="store_true")
@ -718,35 +707,40 @@ def parse_args():
if __name__ == "__main__": if __name__ == "__main__":
args = parse_args() 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": if args.provider == "claude":
provider = cast(RambleProvider, ClaudeCLIProvider( provider = cast(RambleProvider, ClaudeCLIProvider(
cmd=args.claude_cmd, cmd=args.claude_cmd, use_arg_p=True, timeout_s=args.timeout, tail_chars=args.tail, debug=args.debug,
use_arg_p=True,
timeout_s=args.timeout,
tail_chars=args.tail,
debug=args.debug,
)) ))
else: else:
provider = cast(RambleProvider, MockProvider()) provider = cast(RambleProvider, MockProvider())
# Ensure PlantUML exists early (fail fast) # Ensure PlantUML
try: try:
ensure_plantuml_present() ensure_plantuml_present()
except Exception as e: except Exception as e:
print(f"[FATAL] {e}", file=sys.stderr); sys.exit(2) print(f"[FATAL] {e}", file=sys.stderr); sys.exit(2)
if (args.stability or args.pexels) and requests is None: 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) sys.exit(3)
demo = open_ramble_dialog( demo = open_ramble_dialog(
prompt=args.prompt, prompt=args.prompt,
fields=args.fields, fields=args.fields,
field_criteria=criteria,
hints=None, hints=None,
provider=provider, provider=provider,
enable_stability=args.stability, enable_stability=args.stability,
enable_pexels=args.pexels, enable_pexels=args.pexels,
) )
if demo: if demo:
# Emit JSON to stdout (standard practice for tool-style dialogs)
print(json.dumps(demo, ensure_ascii=False, indent=2)) print(json.dumps(demo, ensure_ascii=False, indent=2))