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

544
ramble.py
View File

@ -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
Whats 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 ideas 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 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}
""").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]}")
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"<b>{self._prompt_text}</b>"); 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("<b>Ramble about your idea. Fields will fill themselves.</b>")
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"<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)
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;")
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))